weavatrix 0.2.13 → 0.2.14
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/README.md +83 -39
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.14.md +93 -0
- package/package.json +2 -2
- package/skill/SKILL.md +57 -38
- package/src/analysis/architecture/contract-graph.js +119 -0
- package/src/analysis/architecture/contract-schema.js +110 -0
- package/src/analysis/architecture/contract-storage.js +35 -0
- package/src/analysis/architecture/contract-verification.js +168 -0
- package/src/analysis/architecture-contract.js +16 -343
- package/src/analysis/change-classification/diff-parser.js +103 -0
- package/src/analysis/change-classification/options.js +40 -0
- package/src/analysis/change-classification/symbol-classifier.js +177 -0
- package/src/analysis/change-classification.js +120 -519
- package/src/analysis/dead-code-review/policy.js +23 -0
- package/src/analysis/dead-code-review.js +9 -19
- package/src/analysis/dep-check.js +10 -57
- package/src/analysis/dep-rules.js +10 -303
- package/src/analysis/dependency/scoped-dependencies.js +40 -0
- package/src/analysis/endpoints/common.js +62 -0
- package/src/analysis/endpoints/extract.js +107 -0
- package/src/analysis/endpoints/inventory.js +84 -0
- package/src/analysis/endpoints/mounts.js +129 -0
- package/src/analysis/endpoints-java.js +80 -3
- package/src/analysis/endpoints.js +3 -448
- package/src/analysis/git-history/analytics.js +177 -0
- package/src/analysis/git-history/collector.js +109 -0
- package/src/analysis/git-history/options.js +68 -0
- package/src/analysis/git-history.js +128 -577
- package/src/analysis/health-capabilities.js +82 -0
- package/src/analysis/http-contracts/analysis.js +169 -0
- package/src/analysis/http-contracts/client-call-detection.js +81 -0
- package/src/analysis/http-contracts/client-call-parser.js +255 -0
- package/src/analysis/http-contracts/graph-context.js +143 -0
- package/src/analysis/http-contracts/matching.js +61 -0
- package/src/analysis/http-contracts/shared.js +86 -0
- package/src/analysis/http-contracts.js +6 -822
- package/src/analysis/internal-audit/dependency-health.js +111 -0
- package/src/analysis/internal-audit/python-manifests.js +65 -0
- package/src/analysis/internal-audit/repo-files.js +55 -0
- package/src/analysis/internal-audit/supply-chain.js +132 -0
- package/src/analysis/internal-audit.collect.js +5 -106
- package/src/analysis/internal-audit.run.js +113 -200
- package/src/analysis/jvm-dependency-evidence.js +69 -0
- package/src/analysis/source-correctness/retry-patterns.js +93 -0
- package/src/analysis/source-correctness.js +225 -0
- package/src/analysis/structure/dependency-graph.js +156 -0
- package/src/analysis/structure/findings.js +142 -0
- package/src/graph/builder/go-receiver-resolution.js +112 -0
- package/src/graph/builder/js/queries.js +48 -0
- package/src/graph/builder/lang-go.js +89 -4
- package/src/graph/builder/lang-js.js +4 -44
- package/src/graph/builder/pass2-resolution.js +68 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +3 -2
- package/src/graph/internal-builder.build.js +22 -183
- package/src/graph/internal-builder.pass2.js +161 -0
- package/src/graph/internal-builder.resolvers.js +2 -105
- package/src/graph/resolvers/rust.js +117 -0
- package/src/mcp/actions/advisories.mjs +18 -0
- package/src/mcp/actions/graph-lifecycle.mjs +148 -0
- package/src/mcp/actions/graph-sync.mjs +195 -0
- package/src/mcp/actions/hosted-architecture.mjs +57 -0
- package/src/mcp/architecture-bootstrap.mjs +168 -0
- package/src/mcp/architecture-starter.mjs +234 -0
- package/src/mcp/catalog.mjs +20 -6
- package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
- package/src/mcp/evidence/package-graph-common.mjs +115 -0
- package/src/mcp/evidence/package-lock-graph.mjs +92 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
- package/src/mcp/graph/context-core.mjs +111 -0
- package/src/mcp/graph/context-seeds.mjs +208 -0
- package/src/mcp/graph/context-state.mjs +86 -0
- package/src/mcp/graph/tools-core.mjs +143 -0
- package/src/mcp/graph/tools-query.mjs +223 -0
- package/src/mcp/graph-context.mjs +4 -496
- package/src/mcp/health/audit-format.mjs +172 -0
- package/src/mcp/health/audit.mjs +171 -0
- package/src/mcp/health/dead-code.mjs +110 -0
- package/src/mcp/health/duplicates.mjs +83 -0
- package/src/mcp/health/endpoints.mjs +42 -0
- package/src/mcp/health/structure.mjs +219 -0
- package/src/mcp/runtime-version.mjs +32 -0
- package/src/mcp/server/auto-refresh.mjs +66 -0
- package/src/mcp/server/runtime-config.mjs +43 -0
- package/src/mcp/server/shutdown.mjs +40 -0
- package/src/mcp/sync/evidence-architecture.mjs +54 -0
- package/src/mcp/sync/evidence-common.mjs +88 -0
- package/src/mcp/sync/evidence-duplicates.mjs +100 -0
- package/src/mcp/sync/evidence-health.mjs +56 -0
- package/src/mcp/sync/evidence-packages.mjs +95 -0
- package/src/mcp/sync/payload-common.mjs +97 -0
- package/src/mcp/sync/payload-v2.mjs +53 -0
- package/src/mcp/sync/payload-v3.mjs +79 -0
- package/src/mcp/sync-evidence.mjs +7 -402
- package/src/mcp/sync-payload.mjs +10 -244
- package/src/mcp/tools-actions.mjs +18 -414
- package/src/mcp/tools-architecture.mjs +18 -70
- package/src/mcp/tools-context.mjs +56 -15
- package/src/mcp/tools-endpoints.mjs +4 -1
- package/src/mcp/tools-graph.mjs +17 -331
- package/src/mcp/tools-health.mjs +24 -705
- package/src/mcp-server.mjs +35 -146
- package/src/precision/lsp-client/constants.js +12 -0
- package/src/precision/lsp-client/environment.js +20 -0
- package/src/precision/lsp-client/errors.js +15 -0
- package/src/precision/lsp-client/lifecycle.js +127 -0
- package/src/precision/lsp-client/message-parser.js +81 -0
- package/src/precision/lsp-client/protocol.js +212 -0
- package/src/precision/lsp-client/registry.js +58 -0
- package/src/precision/lsp-client/repo-uri.js +60 -0
- package/src/precision/lsp-client/stdio-client.js +151 -0
- package/src/precision/lsp-client.js +10 -682
- package/src/precision/lsp-overlay/build.js +238 -0
- package/src/precision/lsp-overlay/contract.js +61 -0
- package/src/precision/lsp-overlay/reference-results.js +108 -0
- package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
- package/src/precision/lsp-overlay/source-session.js +134 -0
- package/src/precision/lsp-overlay/store.js +150 -0
- package/src/precision/lsp-overlay/target-index.js +147 -0
- package/src/precision/lsp-overlay/target-query.js +55 -0
- package/src/precision/lsp-overlay.js +11 -868
- package/src/precision/typescript-lsp-provider.js +12 -682
- package/src/precision/typescript-provider/client.js +69 -0
- package/src/precision/typescript-provider/discovery.js +124 -0
- package/src/precision/typescript-provider/project-host.js +249 -0
- package/src/precision/typescript-provider/project-safety.js +161 -0
- package/src/precision/typescript-provider/reference-usage.js +53 -0
- package/src/version.js +7 -0
- package/docs/releases/v0.2.13.md +0 -48
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Conservative source-level correctness review signals. These checks deliberately recognize only
|
|
2
|
+
// a few grounded patterns with local evidence; they are not a compiler, race detector, or proof that
|
|
3
|
+
// the surrounding behavior is wrong.
|
|
4
|
+
import { makeFinding } from "./findings.js";
|
|
5
|
+
import {retryFindings} from './source-correctness/retry-patterns.js'
|
|
6
|
+
|
|
7
|
+
const lineAt = (text, index) => {
|
|
8
|
+
let line = 1;
|
|
9
|
+
for (let i = 0; i < index && i < text.length; i++) if (text[i] === "\n") line++;
|
|
10
|
+
return line;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const lineText = (text, index) => {
|
|
14
|
+
const start = text.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
|
|
15
|
+
const end = text.indexOf("\n", index);
|
|
16
|
+
return text.slice(start, end < 0 ? text.length : end).trim().slice(0, 300);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Preserve byte offsets/newlines while removing comments and string contents from regex input.
|
|
20
|
+
function maskNonCode(text, { hashComments = false } = {}) {
|
|
21
|
+
const chars = String(text || "").split("");
|
|
22
|
+
let quote = "", escaped = false, lineComment = false, blockComment = false;
|
|
23
|
+
for (let i = 0; i < chars.length; i++) {
|
|
24
|
+
const ch = chars[i], next = chars[i + 1];
|
|
25
|
+
if (lineComment) {
|
|
26
|
+
if (ch === "\n" || ch === "\r") lineComment = false;
|
|
27
|
+
else chars[i] = " ";
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (blockComment) {
|
|
31
|
+
if (ch === "*" && next === "/") { chars[i] = chars[i + 1] = " "; i++; blockComment = false; }
|
|
32
|
+
else if (ch !== "\n" && ch !== "\r") chars[i] = " ";
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (quote) {
|
|
36
|
+
if (ch !== "\n" && ch !== "\r") chars[i] = " ";
|
|
37
|
+
if (escaped) escaped = false;
|
|
38
|
+
else if (ch === "\\") escaped = true;
|
|
39
|
+
else if (ch === quote) quote = "";
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (ch === '"' || ch === "'" || ch === "`") { quote = ch; chars[i] = " "; continue; }
|
|
43
|
+
if (hashComments && ch === "#") { chars[i] = " "; lineComment = true; continue; }
|
|
44
|
+
if (ch === "/" && next === "/") { chars[i] = chars[i + 1] = " "; i++; lineComment = true; continue; }
|
|
45
|
+
if (ch === "/" && next === "*") { chars[i] = chars[i + 1] = " "; i++; blockComment = true; }
|
|
46
|
+
}
|
|
47
|
+
return chars.join("");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function balancedEnd(text, openAt) {
|
|
51
|
+
if (text[openAt] !== "{") return -1;
|
|
52
|
+
let depth = 0;
|
|
53
|
+
for (let i = openAt; i < text.length; i++) {
|
|
54
|
+
if (text[i] === "{") depth++;
|
|
55
|
+
else if (text[i] === "}" && --depth === 0) return i + 1;
|
|
56
|
+
}
|
|
57
|
+
return -1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const escapeRegex = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
61
|
+
const upperSnake = (value) => String(value || "")
|
|
62
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
63
|
+
.replace(/[^A-Za-z0-9]+/g, "_")
|
|
64
|
+
.replace(/^_+|_+$/g, "")
|
|
65
|
+
.toUpperCase();
|
|
66
|
+
|
|
67
|
+
function goFunctions(masked) {
|
|
68
|
+
const functions = [];
|
|
69
|
+
const re = /\bfunc\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*\(([^)]*)\)[^{]*\{/g;
|
|
70
|
+
let match;
|
|
71
|
+
while ((match = re.exec(masked))) {
|
|
72
|
+
const open = re.lastIndex - 1;
|
|
73
|
+
const end = balancedEnd(masked, open);
|
|
74
|
+
if (end < 0) continue;
|
|
75
|
+
functions.push({ name: match[1], params: match[2], open, end, body: masked.slice(open + 1, end - 1) });
|
|
76
|
+
re.lastIndex = end;
|
|
77
|
+
}
|
|
78
|
+
return functions;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function goSliceFindings(text, masked, file) {
|
|
82
|
+
const findings = [];
|
|
83
|
+
for (const fn of goFunctions(masked)) {
|
|
84
|
+
const sliceParams = [...fn.params.matchAll(/(?:^|,)\s*([A-Za-z_]\w*)\s+\[\][^,)]*/g)].map((match) => match[1]);
|
|
85
|
+
for (const parameter of sliceParams) {
|
|
86
|
+
const slice = new RegExp(`\\b${escapeRegex(parameter)}\\s*\\[\\s*(?:0\\s*)?:\\s*(\\d+)\\s*\\]`, "g").exec(fn.body);
|
|
87
|
+
if (!slice || Number(slice[1]) < 1) continue;
|
|
88
|
+
const before = fn.body.slice(0, slice.index);
|
|
89
|
+
const lenGuard = new RegExp(`\\blen\\s*\\(\\s*${escapeRegex(parameter)}\\s*\\)`).test(before);
|
|
90
|
+
if (lenGuard) continue;
|
|
91
|
+
const index = fn.open + 1 + slice.index;
|
|
92
|
+
findings.push(makeFinding({
|
|
93
|
+
category: "structure",
|
|
94
|
+
rule: "go-unguarded-fixed-slice",
|
|
95
|
+
severity: "medium",
|
|
96
|
+
confidence: "high",
|
|
97
|
+
title: `Potential slice-bounds panic in ${fn.name}: ${parameter}[:${slice[1]}]`,
|
|
98
|
+
detail: `The slice parameter "${parameter}" is read to a fixed upper bound before any visible len(${parameter}) guard in this function. Confirm every caller guarantees at least ${slice[1]} element(s), or guard the access locally.`,
|
|
99
|
+
file,
|
|
100
|
+
line: lineAt(text, index),
|
|
101
|
+
symbol: `${fn.name}()`,
|
|
102
|
+
evidence: [{ file, line: lineAt(text, index), snippet: lineText(text, index) }],
|
|
103
|
+
fixHint: `validate len(${parameter}) >= ${slice[1]} before slicing`,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return findings;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function goConstructorTypeFindings(text, masked, file) {
|
|
111
|
+
const findings = [];
|
|
112
|
+
for (const fn of goFunctions(masked)) {
|
|
113
|
+
if (!/^New[A-Z]/.test(fn.name)) continue;
|
|
114
|
+
const suffix = upperSnake(fn.name.slice(3));
|
|
115
|
+
if (!suffix) continue;
|
|
116
|
+
const assigned = /\b(?:Type|Kind)\s*:\s*([A-Z][A-Z0-9_]*(?:_TYPE_|_KIND_)[A-Z0-9_]+)\b/g.exec(fn.body);
|
|
117
|
+
if (!assigned) continue;
|
|
118
|
+
const markerAt = Math.max(assigned[1].lastIndexOf("_TYPE_"), assigned[1].lastIndexOf("_KIND_"));
|
|
119
|
+
const markerLength = 6;
|
|
120
|
+
const expected = `${assigned[1].slice(0, markerAt + markerLength)}${suffix}`;
|
|
121
|
+
if (expected === assigned[1] || !new RegExp(`\\b${escapeRegex(expected)}\\b`).test(masked)) continue;
|
|
122
|
+
const index = fn.open + 1 + assigned.index;
|
|
123
|
+
findings.push(makeFinding({
|
|
124
|
+
category: "structure",
|
|
125
|
+
rule: "constructor-enum-mismatch",
|
|
126
|
+
severity: "medium",
|
|
127
|
+
confidence: "high",
|
|
128
|
+
title: `Constructor/type discriminator mismatch: ${fn.name} uses ${assigned[1]}`,
|
|
129
|
+
detail: `The constructor name corresponds to the declared discriminator ${expected}, but the returned literal assigns ${assigned[1]}. This is a focused copy/paste review signal; confirm the intended wire type before changing it.`,
|
|
130
|
+
file,
|
|
131
|
+
line: lineAt(text, index),
|
|
132
|
+
symbol: `${fn.name}()`,
|
|
133
|
+
evidence: [{ file, line: lineAt(text, index), snippet: lineText(text, index) }],
|
|
134
|
+
fixHint: `verify whether the discriminator should be ${expected}`,
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
return findings;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function javaInterruptFindings(text, masked, file) {
|
|
141
|
+
const findings = [];
|
|
142
|
+
const re = /\bcatch\s*\(\s*InterruptedException\s+([A-Za-z_]\w*)\s*\)\s*\{/g;
|
|
143
|
+
let match;
|
|
144
|
+
while ((match = re.exec(masked))) {
|
|
145
|
+
const open = re.lastIndex - 1;
|
|
146
|
+
const end = balancedEnd(masked, open);
|
|
147
|
+
if (end < 0) continue;
|
|
148
|
+
const body = masked.slice(open + 1, end - 1);
|
|
149
|
+
const restores = /\bThread\s*\.\s*currentThread\s*\(\s*\)\s*\.\s*interrupt\s*\(/.test(body);
|
|
150
|
+
const rethrows = new RegExp(`\\bthrow\\s+${escapeRegex(match[1])}\\s*;`).test(body)
|
|
151
|
+
|| /\bthrow\s+new\s+InterruptedException\b/.test(body);
|
|
152
|
+
if (restores || rethrows) continue;
|
|
153
|
+
findings.push(makeFinding({
|
|
154
|
+
category: "structure",
|
|
155
|
+
rule: "java-interrupt-status-not-restored",
|
|
156
|
+
severity: "medium",
|
|
157
|
+
confidence: "high",
|
|
158
|
+
title: "InterruptedException caught without a visible interrupt restore",
|
|
159
|
+
detail: "The catch block neither directly rethrows InterruptedException nor calls Thread.currentThread().interrupt(). A helper may restore it indirectly, so review before changing; otherwise cancellation can be lost.",
|
|
160
|
+
file,
|
|
161
|
+
line: lineAt(text, match.index),
|
|
162
|
+
evidence: [{ file, line: lineAt(text, match.index), snippet: lineText(text, match.index) }],
|
|
163
|
+
fixHint: "restore the interrupt status or propagate the interruption according to the method contract",
|
|
164
|
+
}));
|
|
165
|
+
re.lastIndex = end;
|
|
166
|
+
}
|
|
167
|
+
return findings;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function analyzeSourceCorrectness(sources, { isNonProductPath = () => false } = {}) {
|
|
171
|
+
const findings = [];
|
|
172
|
+
const supportedRuntimeFiles = new Set();
|
|
173
|
+
const concurrencyFiles = new Set();
|
|
174
|
+
const checks = {
|
|
175
|
+
goFixedSlice: { supportedFiles: 0, findings: 0 },
|
|
176
|
+
constructorDiscriminator: { supportedFiles: 0, findings: 0 },
|
|
177
|
+
javaInterrupt: { supportedFiles: 0, findings: 0 },
|
|
178
|
+
retryTermination: { supportedFiles: 0, findings: 0 },
|
|
179
|
+
};
|
|
180
|
+
for (const [file, source] of sources || []) {
|
|
181
|
+
if (isNonProductPath(file)) continue;
|
|
182
|
+
const text = String(source || "");
|
|
183
|
+
const go = /\.go$/i.test(file), java = /\.java$/i.test(file), python = /\.py$/i.test(file);
|
|
184
|
+
const masked = maskNonCode(text, { hashComments: python });
|
|
185
|
+
const retrySupported = /\.(?:[cm]?[jt]sx?|java|go|py)$/i.test(file);
|
|
186
|
+
if (go) {
|
|
187
|
+
supportedRuntimeFiles.add(file);
|
|
188
|
+
checks.goFixedSlice.supportedFiles++;
|
|
189
|
+
checks.constructorDiscriminator.supportedFiles++;
|
|
190
|
+
const slice = goSliceFindings(text, masked, file);
|
|
191
|
+
const discriminator = goConstructorTypeFindings(text, masked, file);
|
|
192
|
+
findings.push(...slice, ...discriminator);
|
|
193
|
+
checks.goFixedSlice.findings += slice.length;
|
|
194
|
+
checks.constructorDiscriminator.findings += discriminator.length;
|
|
195
|
+
}
|
|
196
|
+
if (java) {
|
|
197
|
+
supportedRuntimeFiles.add(file);
|
|
198
|
+
concurrencyFiles.add(file);
|
|
199
|
+
checks.javaInterrupt.supportedFiles++;
|
|
200
|
+
const interruption = javaInterruptFindings(text, masked, file);
|
|
201
|
+
findings.push(...interruption);
|
|
202
|
+
checks.javaInterrupt.findings += interruption.length;
|
|
203
|
+
}
|
|
204
|
+
if (retrySupported) {
|
|
205
|
+
supportedRuntimeFiles.add(file);
|
|
206
|
+
checks.retryTermination.supportedFiles++;
|
|
207
|
+
const retry = retryFindings(text, masked, file, {python});
|
|
208
|
+
findings.push(...retry);
|
|
209
|
+
checks.retryTermination.findings += retry.length;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
findings,
|
|
214
|
+
coverage: {
|
|
215
|
+
runtimeCorrectnessFiles: supportedRuntimeFiles.size,
|
|
216
|
+
concurrencyFiles: concurrencyFiles.size,
|
|
217
|
+
checks,
|
|
218
|
+
limitations: [
|
|
219
|
+
"bounded source patterns only",
|
|
220
|
+
"no compiler or runtime execution",
|
|
221
|
+
"no race detector; race freedom is not claimed",
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {ENTRY_FILE} from '../dead-check.js'
|
|
2
|
+
|
|
3
|
+
const TEST_FILE_RE = /(^|[/])(test|tests|__tests__|spec|e2e|__mocks__)([/]|$)|[._-](test|spec)\.[a-z0-9]+$/i
|
|
4
|
+
const NON_CODE_RE = /\.(json|ya?ml|sh|ps1|md|txt|html?|css|scss|less)$|(^|[/])(dockerfile|containerfile)/i
|
|
5
|
+
const endpoint = (value) => String(value && typeof value === 'object' ? value.id : value)
|
|
6
|
+
const fileOf = (value) => {
|
|
7
|
+
const id = endpoint(value)
|
|
8
|
+
const hash = id.indexOf('#')
|
|
9
|
+
return hash < 0 ? id : id.slice(0, hash)
|
|
10
|
+
}
|
|
11
|
+
const dirOf = (file) => file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : ''
|
|
12
|
+
|
|
13
|
+
export function buildFileImportGraph(graph, {includeTypeOnly = false, includeCompileOnly = false} = {}) {
|
|
14
|
+
const fileIds = new Set()
|
|
15
|
+
for (const node of graph.nodes || []) if (!String(node.id).includes('#')) fileIds.add(String(node.id))
|
|
16
|
+
const runtimeAdj = new Map(), allAdj = new Map()
|
|
17
|
+
const edges = [], allEdges = [], typeOnlyEdges = [], compileOnlyEdges = [], compileTimeEdges = []
|
|
18
|
+
const runtimeSeen = new Set(), allSeen = new Set(), typeSeen = new Set(), compileSeen = new Set(), compileTimeSeen = new Set()
|
|
19
|
+
const add = (map, source, target) => {
|
|
20
|
+
let targets = map.get(source)
|
|
21
|
+
if (!targets) map.set(source, (targets = new Set()))
|
|
22
|
+
targets.add(target)
|
|
23
|
+
}
|
|
24
|
+
for (const link of graph.links || []) {
|
|
25
|
+
if (link.relation !== 'imports' && link.relation !== 're_exports') continue
|
|
26
|
+
const source = endpoint(link.source), target = endpoint(link.target)
|
|
27
|
+
if (!fileIds.has(source) || !fileIds.has(target) || source === target) continue
|
|
28
|
+
if (source.endsWith('.go') && target.endsWith('.go') && dirOf(source) === dirOf(target)) continue
|
|
29
|
+
const key = `${source}\0${target}`
|
|
30
|
+
if (!allSeen.has(key)) { allSeen.add(key); add(allAdj, source, target); allEdges.push([source, target]) }
|
|
31
|
+
if (link.typeOnly === true || link.compileOnly === true) {
|
|
32
|
+
if (link.typeOnly === true && !typeSeen.has(key)) { typeSeen.add(key); typeOnlyEdges.push([source, target]) }
|
|
33
|
+
if (link.compileOnly === true && !compileSeen.has(key)) { compileSeen.add(key); compileOnlyEdges.push([source, target]) }
|
|
34
|
+
if (!compileTimeSeen.has(key)) { compileTimeSeen.add(key); compileTimeEdges.push([source, target]) }
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (!runtimeSeen.has(key)) { runtimeSeen.add(key); add(runtimeAdj, source, target); edges.push([source, target]) }
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
fileIds,
|
|
41
|
+
adj: includeTypeOnly || includeCompileOnly ? allAdj : runtimeAdj,
|
|
42
|
+
edges: includeTypeOnly || includeCompileOnly ? allEdges : edges,
|
|
43
|
+
runtimeAdj,
|
|
44
|
+
runtimeEdges: edges,
|
|
45
|
+
allAdj,
|
|
46
|
+
allEdges,
|
|
47
|
+
typeOnlyEdges: typeOnlyEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`)),
|
|
48
|
+
compileOnlyEdges: compileOnlyEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`)),
|
|
49
|
+
compileTimeEdges: compileTimeEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`)),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Iterative Tarjan: repository graphs can be deep enough to overflow recursive JavaScript.
|
|
54
|
+
export function findSccs(adjacency) {
|
|
55
|
+
const index = new Map(), low = new Map(), onStack = new Set(), nodes = []
|
|
56
|
+
let counter = 0
|
|
57
|
+
const components = []
|
|
58
|
+
for (const root of adjacency.keys()) {
|
|
59
|
+
if (index.has(root)) continue
|
|
60
|
+
index.set(root, counter); low.set(root, counter); counter++
|
|
61
|
+
nodes.push(root); onStack.add(root)
|
|
62
|
+
const stack = [{value: root, child: 0, neighbors: [...(adjacency.get(root) || [])]}]
|
|
63
|
+
while (stack.length) {
|
|
64
|
+
const frame = stack[stack.length - 1]
|
|
65
|
+
if (frame.child < frame.neighbors.length) {
|
|
66
|
+
const next = frame.neighbors[frame.child++]
|
|
67
|
+
if (!index.has(next)) {
|
|
68
|
+
index.set(next, counter); low.set(next, counter); counter++
|
|
69
|
+
nodes.push(next); onStack.add(next)
|
|
70
|
+
stack.push({value: next, child: 0, neighbors: [...(adjacency.get(next) || [])]})
|
|
71
|
+
} else if (onStack.has(next)) low.set(frame.value, Math.min(low.get(frame.value), index.get(next)))
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
stack.pop()
|
|
75
|
+
if (stack.length) {
|
|
76
|
+
const parent = stack[stack.length - 1]
|
|
77
|
+
low.set(parent.value, Math.min(low.get(parent.value), low.get(frame.value)))
|
|
78
|
+
}
|
|
79
|
+
if (low.get(frame.value) !== index.get(frame.value)) continue
|
|
80
|
+
const component = []
|
|
81
|
+
let value
|
|
82
|
+
do { value = nodes.pop(); onStack.delete(value); component.push(value) } while (value !== frame.value)
|
|
83
|
+
if (component.length > 1) components.push(component)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return components
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function representativeCycle(adjacency, component) {
|
|
90
|
+
const members = new Set(component)
|
|
91
|
+
const start = [...component].sort()[0]
|
|
92
|
+
const previous = new Map([[start, null]])
|
|
93
|
+
const queue = [start]
|
|
94
|
+
while (queue.length) {
|
|
95
|
+
const current = queue.shift()
|
|
96
|
+
for (const next of [...(adjacency.get(current) || [])].sort()) {
|
|
97
|
+
if (next === start) {
|
|
98
|
+
const path = []
|
|
99
|
+
for (let value = current; value != null; value = previous.get(value)) path.push(value)
|
|
100
|
+
return [...path.reverse(), start]
|
|
101
|
+
}
|
|
102
|
+
if (!members.has(next) || previous.has(next)) continue
|
|
103
|
+
previous.set(next, current)
|
|
104
|
+
queue.push(next)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const fallback = [...component].sort()
|
|
108
|
+
return [...fallback, fallback[0]]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function findOrphans(graph, {entrySet = new Set(), externalImportFiles = new Set()} = {}) {
|
|
112
|
+
const degree = new Map()
|
|
113
|
+
for (const link of graph.links || []) {
|
|
114
|
+
if (link.relation === 'contains') continue
|
|
115
|
+
const source = fileOf(link.source), target = fileOf(link.target)
|
|
116
|
+
if (source === target) continue
|
|
117
|
+
degree.set(source, (degree.get(source) || 0) + 1)
|
|
118
|
+
degree.set(target, (degree.get(target) || 0) + 1)
|
|
119
|
+
}
|
|
120
|
+
const out = []
|
|
121
|
+
for (const node of graph.nodes || []) {
|
|
122
|
+
const id = String(node.id)
|
|
123
|
+
if (id.includes('#') || (degree.get(id) || 0) > 0) continue
|
|
124
|
+
const file = node.source_file
|
|
125
|
+
if (entrySet.has(file) || ENTRY_FILE.test(file) || TEST_FILE_RE.test(file) || NON_CODE_RE.test(file)) continue
|
|
126
|
+
out.push({file, importsExternals: externalImportFiles.has(file)})
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function globToRe(glob) {
|
|
132
|
+
const parts = String(glob).split('**').map((part) => part
|
|
133
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]'))
|
|
134
|
+
let source = parts.join('.*')
|
|
135
|
+
source = source.replace(/\/\.\*\//g, '/(?:.*/)?').replace(/^\.\*\//, '(?:.*/)?')
|
|
136
|
+
return new RegExp(`^${source}$`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function checkBoundaries(edges, rules = {}) {
|
|
140
|
+
const violations = []
|
|
141
|
+
const forbidden = (rules.forbidden || []).map((rule) => ({...rule, fromRe: globToRe(rule.from), toRe: globToRe(rule.to)}))
|
|
142
|
+
const allowedOnly = (rules.allowedOnly || []).map((rule) => ({
|
|
143
|
+
...rule,
|
|
144
|
+
fromRe: globToRe(rule.from),
|
|
145
|
+
toRes: (Array.isArray(rule.to) ? rule.to : [rule.to]).map(globToRe),
|
|
146
|
+
}))
|
|
147
|
+
for (const [source, target] of edges) {
|
|
148
|
+
for (const rule of forbidden) if (rule.fromRe.test(source) && rule.toRe.test(target)) {
|
|
149
|
+
violations.push({name: rule.name, comment: rule.comment || '', severity: rule.severity, from: source, to: target, kind: 'forbidden'})
|
|
150
|
+
}
|
|
151
|
+
for (const rule of allowedOnly) if (rule.fromRe.test(source) && !rule.toRes.some((regexp) => regexp.test(target))) {
|
|
152
|
+
violations.push({name: rule.name, comment: rule.comment || '', severity: rule.severity, from: source, to: target, kind: 'allowedOnly'})
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return violations
|
|
156
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {makeFinding} from '../findings.js'
|
|
2
|
+
import {formatRepresentativeCycle} from '../cycle-route.js'
|
|
3
|
+
import {
|
|
4
|
+
buildFileImportGraph,
|
|
5
|
+
checkBoundaries,
|
|
6
|
+
findOrphans,
|
|
7
|
+
findSccs,
|
|
8
|
+
representativeCycle,
|
|
9
|
+
} from './dependency-graph.js'
|
|
10
|
+
|
|
11
|
+
const MAX_CYCLE_FINDINGS = 50
|
|
12
|
+
const MAX_BOUNDARY_FINDINGS = 100
|
|
13
|
+
|
|
14
|
+
const edgeCountIn = (component, edges) => {
|
|
15
|
+
const inside = new Set(component)
|
|
16
|
+
return edges.reduce((count, [source, target]) => count + (inside.has(source) && inside.has(target) ? 1 : 0), 0)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function runtimeCycleFinding(adjacency, component) {
|
|
20
|
+
const cycle = representativeCycle(adjacency, component)
|
|
21
|
+
const cycleRoute = formatRepresentativeCycle(cycle)
|
|
22
|
+
return makeFinding({
|
|
23
|
+
category: 'structure',
|
|
24
|
+
rule: 'circular-dep',
|
|
25
|
+
severity: component.length > 4 ? 'high' : 'medium',
|
|
26
|
+
confidence: 'high',
|
|
27
|
+
title: `Circular dependency: ${component.length} files`,
|
|
28
|
+
detail: `${cycleRoute}${component.length + 1 > cycle.length ? ` (representative loop; the tangle spans ${component.length} files)` : ''}. Break the cycle by extracting the shared piece or inverting one import.`,
|
|
29
|
+
cycleRoute,
|
|
30
|
+
cycleMembers: [...component].sort(),
|
|
31
|
+
file: cycle[0],
|
|
32
|
+
graphNodeId: cycle[0],
|
|
33
|
+
evidence: cycle.map((file) => ({file, line: 0, snippet: ''})),
|
|
34
|
+
source: 'internal',
|
|
35
|
+
fixHint: 'extract the shared code into a module both sides import, or invert the weaker dependency',
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compileTimeFinding(adjacency, component, runtimeComponents, edges) {
|
|
40
|
+
const cycle = representativeCycle(adjacency, component)
|
|
41
|
+
const cycleRoute = formatRepresentativeCycle(cycle)
|
|
42
|
+
const runtimeInside = edgeCountIn(component, edges.runtime)
|
|
43
|
+
const typeInside = edgeCountIn(component, edges.typeOnly)
|
|
44
|
+
const compileInside = edgeCountIn(component, edges.compileOnly)
|
|
45
|
+
const containsRuntimeCycle = runtimeComponents.some((runtime) => runtime.every((file) => component.includes(file)))
|
|
46
|
+
const typeSpecific = compileInside === 0
|
|
47
|
+
return makeFinding({
|
|
48
|
+
category: 'structure',
|
|
49
|
+
rule: typeSpecific ? 'type-coupling' : 'compile-time-coupling',
|
|
50
|
+
severity: 'info',
|
|
51
|
+
confidence: 'high',
|
|
52
|
+
title: `${containsRuntimeCycle
|
|
53
|
+
? (typeSpecific ? 'Type imports expand dependency coupling' : 'Compile-time edges expand dependency coupling')
|
|
54
|
+
: (typeSpecific ? 'Type-induced dependency cycle (no runtime cycle)' : 'Compile-time dependency cycle (no runtime cycle)')}: ${component.length} files`,
|
|
55
|
+
detail: `${cycleRoute}. This strongly-connected group needs compile-time-only edges to close; it contains ${runtimeInside} runtime edge(s), ${typeInside} type-only edge(s), and ${compileInside} compile-only edge(s)${containsRuntimeCycle ? ', with a smaller runtime cycle reported separately' : ', while its runtime import graph is acyclic'}. Treat this as design coupling, not an initialization-order failure.`,
|
|
56
|
+
cycleRoute,
|
|
57
|
+
cycleMembers: [...component].sort(),
|
|
58
|
+
file: cycle[0],
|
|
59
|
+
graphNodeId: cycle[0],
|
|
60
|
+
evidence: cycle.map((file) => ({file, line: 0, snippet: ''})),
|
|
61
|
+
source: 'internal',
|
|
62
|
+
fixHint: 'review the compile-time ownership only if the coupling impedes changes; no runtime-cycle fix is required',
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function computeStructureFindings(graph, {rules = {}, entrySet = new Set(), externalImportFiles = new Set()} = {}) {
|
|
67
|
+
const imports = buildFileImportGraph(graph)
|
|
68
|
+
const findings = []
|
|
69
|
+
const runtimeComponents = findSccs(imports.adj).sort((a, b) => b.length - a.length)
|
|
70
|
+
for (const component of runtimeComponents.slice(0, MAX_CYCLE_FINDINGS)) {
|
|
71
|
+
findings.push(runtimeCycleFinding(imports.adj, component))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const runtimeKeys = new Set(runtimeComponents.map((component) => [...component].sort().join('\0')))
|
|
75
|
+
const allComponents = findSccs(imports.allAdj).sort((a, b) => b.length - a.length)
|
|
76
|
+
const compileTimeCouplings = allComponents.filter((component) => !runtimeKeys.has([...component].sort().join('\0')))
|
|
77
|
+
for (const component of compileTimeCouplings.slice(0, MAX_CYCLE_FINDINGS)) {
|
|
78
|
+
findings.push(compileTimeFinding(imports.allAdj, component, runtimeComponents, {
|
|
79
|
+
runtime: imports.edges,
|
|
80
|
+
typeOnly: imports.typeOnlyEdges,
|
|
81
|
+
compileOnly: imports.compileOnlyEdges,
|
|
82
|
+
}))
|
|
83
|
+
}
|
|
84
|
+
if (runtimeComponents.length > MAX_CYCLE_FINDINGS) findings.push(makeFinding({
|
|
85
|
+
category: 'structure', rule: 'circular-dep', severity: 'info', confidence: 'high',
|
|
86
|
+
title: `…and ${runtimeComponents.length - MAX_CYCLE_FINDINGS} more dependency cycles`,
|
|
87
|
+
detail: `Cycle findings are capped at ${MAX_CYCLE_FINDINGS}; ${runtimeComponents.length} strongly-connected groups exist in total.`,
|
|
88
|
+
source: 'internal',
|
|
89
|
+
}))
|
|
90
|
+
|
|
91
|
+
for (const orphan of findOrphans(graph, {entrySet, externalImportFiles})) findings.push(makeFinding({
|
|
92
|
+
category: 'structure',
|
|
93
|
+
rule: 'orphan-file',
|
|
94
|
+
severity: 'info',
|
|
95
|
+
confidence: orphan.importsExternals ? 'low' : 'medium',
|
|
96
|
+
title: `Orphan file: ${orphan.file}`,
|
|
97
|
+
detail: `No repo file imports it and it imports/calls nothing in the repo${orphan.importsExternals ? ' (it does use npm packages — possibly a standalone script or tool)' : ''}. Possibly dead, possibly an undeclared entry point.`,
|
|
98
|
+
file: orphan.file,
|
|
99
|
+
graphNodeId: orphan.file,
|
|
100
|
+
source: 'internal',
|
|
101
|
+
}))
|
|
102
|
+
|
|
103
|
+
const violations = checkBoundaries(imports.edges, rules)
|
|
104
|
+
for (const item of violations.slice(0, MAX_BOUNDARY_FINDINGS)) findings.push(makeFinding({
|
|
105
|
+
category: 'structure',
|
|
106
|
+
rule: 'boundary-violation',
|
|
107
|
+
severity: ['critical', 'high', 'medium', 'low', 'info'].includes(item.severity) ? item.severity : 'medium',
|
|
108
|
+
confidence: 'high',
|
|
109
|
+
title: `Boundary violation (${item.name}): ${item.from} → ${item.to}`,
|
|
110
|
+
detail: `${item.kind === 'allowedOnly' ? 'Import leaves the allowed set' : 'Forbidden import'}${item.comment ? `: ${item.comment}` : ''}.`,
|
|
111
|
+
file: item.from,
|
|
112
|
+
graphNodeId: item.from,
|
|
113
|
+
evidence: [{file: item.from, line: 0, snippet: `imports ${item.to}`}],
|
|
114
|
+
source: 'internal',
|
|
115
|
+
}))
|
|
116
|
+
if (violations.length > MAX_BOUNDARY_FINDINGS) findings.push(makeFinding({
|
|
117
|
+
category: 'structure', rule: 'boundary-violation', severity: 'info', confidence: 'high',
|
|
118
|
+
title: `…and ${violations.length - MAX_BOUNDARY_FINDINGS} more boundary violations`,
|
|
119
|
+
detail: `Boundary findings are capped at ${MAX_BOUNDARY_FINDINGS}; ${violations.length} edges violate the rules in total.`,
|
|
120
|
+
source: 'internal',
|
|
121
|
+
}))
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
findings,
|
|
125
|
+
stats: {
|
|
126
|
+
importEdges: imports.allEdges.length,
|
|
127
|
+
runtimeImportEdges: imports.edges.length,
|
|
128
|
+
typeOnlyImportEdges: imports.typeOnlyEdges.length,
|
|
129
|
+
compileOnlyImportEdges: imports.compileOnlyEdges.length,
|
|
130
|
+
compileTimeImportEdges: imports.compileTimeEdges.length,
|
|
131
|
+
cycles: runtimeComponents.length,
|
|
132
|
+
runtimeCycles: runtimeComponents.length,
|
|
133
|
+
largestCycle: runtimeComponents[0]?.length || 0,
|
|
134
|
+
typeCouplings: compileTimeCouplings.length,
|
|
135
|
+
largestTypeCoupling: compileTimeCouplings[0]?.length || 0,
|
|
136
|
+
compileTimeCouplings: compileTimeCouplings.length,
|
|
137
|
+
largestCompileTimeCoupling: compileTimeCouplings[0]?.length || 0,
|
|
138
|
+
orphans: findings.filter((finding) => finding.rule === 'orphan-file').length,
|
|
139
|
+
boundaryViolations: violations.length,
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {goTypeName} from './lang-go.js'
|
|
2
|
+
|
|
3
|
+
const dirOf = (file) => file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : ''
|
|
4
|
+
|
|
5
|
+
export function createGoReceiverResolution({resolution, nodeById, importedLocals}) {
|
|
6
|
+
const {dirSymbols, dirMethods, dirMethodsByName, dirTypes, resolveCall} = resolution
|
|
7
|
+
const typeRef = (rawType, contextFile, fallbackDir = dirOf(contextFile)) => {
|
|
8
|
+
const raw = String(rawType || '').trim()
|
|
9
|
+
if (!raw) return null
|
|
10
|
+
const dot = raw.indexOf('.')
|
|
11
|
+
if (dot > 0) {
|
|
12
|
+
const imported = importedLocals.get(contextFile)?.get(raw.slice(0, dot))
|
|
13
|
+
return imported?.targetDir ? {dir: imported.targetDir, name: raw.slice(dot + 1)} : null
|
|
14
|
+
}
|
|
15
|
+
return {dir: fallbackDir, name: raw}
|
|
16
|
+
}
|
|
17
|
+
const fieldType = (receiver, fieldName) => {
|
|
18
|
+
if (!receiver?.name || !fieldName) return null
|
|
19
|
+
const typeId = dirTypes.get(receiver.dir)?.get(receiver.name)
|
|
20
|
+
const typeNode = typeId && nodeById.get(typeId)
|
|
21
|
+
const rawFieldType = typeNode?.field_types?.[fieldName]
|
|
22
|
+
return rawFieldType ? typeRef(rawFieldType, typeNode.source_file, receiver.dir) : null
|
|
23
|
+
}
|
|
24
|
+
const exactMethod = (receiver, methodName) => {
|
|
25
|
+
const candidates = receiver && dirMethods.get(receiver.dir)?.get(receiver.name)?.get(methodName)
|
|
26
|
+
return candidates?.length === 1 ? candidates[0] : null
|
|
27
|
+
}
|
|
28
|
+
const uniqueMethod = (dir, methodName) => {
|
|
29
|
+
const candidates = dirMethodsByName.get(dir)?.get(methodName) || []
|
|
30
|
+
return candidates.length === 1 ? candidates[0] : null
|
|
31
|
+
}
|
|
32
|
+
const returnType = (targetId, callerFile) => {
|
|
33
|
+
const target = targetId && nodeById.get(targetId)
|
|
34
|
+
return target?.return_type
|
|
35
|
+
? typeRef(target.return_type, target.source_file || callerFile, dirOf(target.source_file || callerFile))
|
|
36
|
+
: null
|
|
37
|
+
}
|
|
38
|
+
const fieldChildren = (node, name) => {
|
|
39
|
+
const children = node?.childrenForFieldName?.(name)
|
|
40
|
+
if (Array.isArray(children) && children.length) return children
|
|
41
|
+
const child = node?.childForFieldName?.(name)
|
|
42
|
+
return child ? [child] : []
|
|
43
|
+
}
|
|
44
|
+
const receiverBindings = (selector, file) => {
|
|
45
|
+
const bindings = new Map()
|
|
46
|
+
let scope = selector
|
|
47
|
+
while (scope?.parent && !['function_declaration', 'method_declaration'].includes(scope.type)) scope = scope.parent
|
|
48
|
+
if (!scope || !['function_declaration', 'method_declaration'].includes(scope.type)) return bindings
|
|
49
|
+
const callRow = selector.startPosition.row
|
|
50
|
+
const containsCall = (node) => node.startIndex <= selector.startIndex && node.endIndex >= selector.endIndex
|
|
51
|
+
const expressionType = (node) => {
|
|
52
|
+
if (!node) return null
|
|
53
|
+
const declared = goTypeName(node)
|
|
54
|
+
if (declared) return typeRef(declared, file)
|
|
55
|
+
if (['parenthesized_expression', 'unary_expression'].includes(node.type)) {
|
|
56
|
+
return expressionType(node.childForFieldName?.('operand') || node.namedChildren?.[0])
|
|
57
|
+
}
|
|
58
|
+
if (node.type === 'composite_literal') return typeRef(goTypeName(node.childForFieldName?.('type')), file)
|
|
59
|
+
if (node.type === 'identifier') return bindings.get(node.text) || null
|
|
60
|
+
if (node.type === 'selector_expression') return fieldType(expressionType(node.childForFieldName?.('operand')), node.childForFieldName?.('field')?.text)
|
|
61
|
+
if (node.type !== 'call_expression') return null
|
|
62
|
+
const fn = node.childForFieldName?.('function')
|
|
63
|
+
if (fn?.type === 'identifier') {
|
|
64
|
+
const returned = returnType(resolveCall(fn.text, file), file)
|
|
65
|
+
if (returned) return returned
|
|
66
|
+
if (dirTypes.get(dirOf(file))?.has(fn.text)) return typeRef(fn.text, file)
|
|
67
|
+
if (/^New[A-Z_]/.test(fn.text)) return typeRef(fn.text.slice(3), file)
|
|
68
|
+
}
|
|
69
|
+
if (fn?.type === 'selector_expression') {
|
|
70
|
+
const operand = fn.childForFieldName?.('operand'), member = fn.childForFieldName?.('field')?.text
|
|
71
|
+
const imported = operand?.type === 'identifier' && importedLocals.get(file)?.get(operand.text)
|
|
72
|
+
if (imported?.targetDir && member) {
|
|
73
|
+
const returned = returnType(dirSymbols.get(imported.targetDir)?.get(member), file)
|
|
74
|
+
if (returned) return returned
|
|
75
|
+
if (/^New[A-Z_]/.test(member)) return {dir: imported.targetDir, name: member.slice(3)}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
const bindTyped = (node) => {
|
|
81
|
+
const reference = typeRef(goTypeName(node.childForFieldName?.('type')), file)
|
|
82
|
+
const names = fieldChildren(node, 'name').filter((name) => ['identifier', 'field_identifier'].includes(name.type))
|
|
83
|
+
if (reference) for (const name of names) bindings.set(name.text, reference)
|
|
84
|
+
else if (node.type === 'var_spec') {
|
|
85
|
+
const values = node.childForFieldName?.('value')?.namedChildren || []
|
|
86
|
+
for (let index = 0; index < Math.min(names.length, values.length); index++) {
|
|
87
|
+
const inferred = expressionType(values[index])
|
|
88
|
+
if (inferred) bindings.set(names[index].text, inferred)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const bindAssignment = (node) => {
|
|
93
|
+
const left = node.childForFieldName?.('left')?.namedChildren || []
|
|
94
|
+
const right = node.childForFieldName?.('right')?.namedChildren || []
|
|
95
|
+
for (let index = 0; index < Math.min(left.length, right.length); index++) {
|
|
96
|
+
if (left[index].type !== 'identifier') continue
|
|
97
|
+
const inferred = expressionType(right[index])
|
|
98
|
+
if (inferred) bindings.set(left[index].text, inferred)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const visit = (node) => {
|
|
102
|
+
if (!node || node.startPosition.row > callRow) return
|
|
103
|
+
if (node !== scope && ['function_declaration', 'method_declaration', 'func_literal'].includes(node.type) && !containsCall(node)) return
|
|
104
|
+
if (['parameter_declaration', 'var_spec'].includes(node.type)) bindTyped(node)
|
|
105
|
+
else if (['short_var_declaration', 'assignment_statement'].includes(node.type)) bindAssignment(node)
|
|
106
|
+
for (const child of node.namedChildren || []) visit(child)
|
|
107
|
+
}
|
|
108
|
+
visit(scope)
|
|
109
|
+
return bindings
|
|
110
|
+
}
|
|
111
|
+
return {dirOf, fieldType, exactMethod, uniqueMethod, receiverBindings}
|
|
112
|
+
}
|