@orangepro/orangepro-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +328 -0
- package/dist/local/agentWorkflow.js +81 -0
- package/dist/local/aiGraph/links.js +635 -0
- package/dist/local/analyze/analyzer.js +2129 -0
- package/dist/local/analyze/behaviorContracts.js +169 -0
- package/dist/local/analyze/boilerplate.js +42 -0
- package/dist/local/analyze/callGraph.js +458 -0
- package/dist/local/analyze/classify.js +219 -0
- package/dist/local/analyze/clustering.js +357 -0
- package/dist/local/analyze/confirm.js +2422 -0
- package/dist/local/analyze/coverage.js +518 -0
- package/dist/local/analyze/coverageArtifacts.js +607 -0
- package/dist/local/analyze/frameworks.js +115 -0
- package/dist/local/analyze/linkage/conventions.js +160 -0
- package/dist/local/analyze/parseCache.js +164 -0
- package/dist/local/analyze/selfAssert.js +53 -0
- package/dist/local/analyze/symbols.js +430 -0
- package/dist/local/analyze/testLayer.js +135 -0
- package/dist/local/analyze/treeSitter/engine.js +1253 -0
- package/dist/local/analyze/treeSitter/languages.js +101 -0
- package/dist/local/autoProve.js +620 -0
- package/dist/local/cli.js +1468 -0
- package/dist/local/cliArgs.js +112 -0
- package/dist/local/corpusScope.js +162 -0
- package/dist/local/enrich/csv.js +348 -0
- package/dist/local/enrich/index.js +43 -0
- package/dist/local/enrich/markdown.js +193 -0
- package/dist/local/explain/explain.js +91 -0
- package/dist/local/exportCli.js +26 -0
- package/dist/local/flows/flowWalker.js +215 -0
- package/dist/local/flows/llmFlowDiscovery.js +567 -0
- package/dist/local/freshness/changed.js +280 -0
- package/dist/local/freshness/manifest.js +35 -0
- package/dist/local/freshness/status.js +30 -0
- package/dist/local/gaps/gaps.js +114 -0
- package/dist/local/generate/buckets.js +73 -0
- package/dist/local/generate/compareJudge.js +124 -0
- package/dist/local/generate/compareReport.js +538 -0
- package/dist/local/generate/compareScore.js +105 -0
- package/dist/local/generate/deriveImports.js +91 -0
- package/dist/local/generate/generator.js +2586 -0
- package/dist/local/generate/prompt.js +144 -0
- package/dist/local/generate/promptV5.js +438 -0
- package/dist/local/generate/providers.js +400 -0
- package/dist/local/generate/runHints.js +304 -0
- package/dist/local/graph/citations.js +73 -0
- package/dist/local/graph/confirmable.js +72 -0
- package/dist/local/graph/factories.js +210 -0
- package/dist/local/graph/ontology.js +18 -0
- package/dist/local/interactive.js +53 -0
- package/dist/local/jobs/jobStore.js +80 -0
- package/dist/local/jobs/notify.js +29 -0
- package/dist/local/jobs/runner.js +75 -0
- package/dist/local/ledger.js +117 -0
- package/dist/local/localConfig.js +112 -0
- package/dist/local/mcp.js +548 -0
- package/dist/local/operations.js +1749 -0
- package/dist/local/pack/coverageReport.js +192 -0
- package/dist/local/pack/exporter.js +195 -0
- package/dist/local/pack/schema.js +128 -0
- package/dist/local/pack/summary.js +127 -0
- package/dist/local/pack/validate.js +25 -0
- package/dist/local/proofRunnability.js +366 -0
- package/dist/local/recipe/dbSqljs.js +255 -0
- package/dist/local/reprove/paths.js +13 -0
- package/dist/local/reprove/scoped.js +136 -0
- package/dist/local/resolve/barrelWalker.js +178 -0
- package/dist/local/resolve/exportIndex.js +270 -0
- package/dist/local/resolve/importGraph.js +347 -0
- package/dist/local/resolve/resolver.js +122 -0
- package/dist/local/resolve/resolverCache.js +117 -0
- package/dist/local/rtm.js +413 -0
- package/dist/local/score/coverage.js +99 -0
- package/dist/local/score/doctor.js +67 -0
- package/dist/local/score/risk.js +362 -0
- package/dist/local/score/score.js +182 -0
- package/dist/local/types.js +1 -0
- package/dist/local/util/hash.js +16 -0
- package/dist/local/util/ids.js +16 -0
- package/dist/local/util/progress.js +8 -0
- package/dist/local/util/redact.js +39 -0
- package/dist/local/util/time.js +1 -0
- package/dist/local/util/walk.js +174 -0
- package/dist/local/viz/behaviorReportData.js +367 -0
- package/dist/local/viz/behaviorReportHtml.js +664 -0
- package/dist/local/viz/d3.bundle.js +3 -0
- package/dist/local/viz/html.js +1152 -0
- package/dist/local/viz/payload.js +525 -0
- package/dist/local/workspace.js +99 -0
- package/docs/agent-workflow.md +167 -0
- package/docs/agents/claude-code.md +43 -0
- package/docs/agents/codex.md +52 -0
- package/docs/agents/cursor.md +39 -0
- package/docs/agents/opencode.md +43 -0
- package/docs/agents/vscode.md +34 -0
- package/docs/local-proof-kit.md +269 -0
- package/package.json +92 -0
- package/scripts/spikes/dynamic-proof-jest-reporter.cjs +66 -0
- package/scripts/spikes/dynamic-proof-mocha-reporter.cjs +105 -0
- package/scripts/spikes/dynamic-proof-spike.mjs +2335 -0
- package/scripts/spikes/dynamic-proof-vitest-reporter.mjs +81 -0
- package/scripts/spikes/failure-summary.mjs +29 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
const LANGUAGE_BY_EXT = {
|
|
2
|
+
ts: "typescript",
|
|
3
|
+
tsx: "typescript",
|
|
4
|
+
mts: "typescript",
|
|
5
|
+
cts: "typescript",
|
|
6
|
+
js: "javascript",
|
|
7
|
+
jsx: "javascript",
|
|
8
|
+
mjs: "javascript",
|
|
9
|
+
cjs: "javascript",
|
|
10
|
+
py: "python",
|
|
11
|
+
go: "go",
|
|
12
|
+
rb: "ruby",
|
|
13
|
+
java: "java",
|
|
14
|
+
kt: "kotlin",
|
|
15
|
+
rs: "rust",
|
|
16
|
+
php: "php",
|
|
17
|
+
cs: "csharp",
|
|
18
|
+
swift: "swift",
|
|
19
|
+
c: "c",
|
|
20
|
+
h: "c",
|
|
21
|
+
cc: "cpp",
|
|
22
|
+
cpp: "cpp",
|
|
23
|
+
cxx: "cpp",
|
|
24
|
+
hh: "cpp",
|
|
25
|
+
hpp: "cpp",
|
|
26
|
+
hxx: "cpp",
|
|
27
|
+
md: "markdown",
|
|
28
|
+
json: "json",
|
|
29
|
+
yaml: "yaml",
|
|
30
|
+
yml: "yaml",
|
|
31
|
+
toml: "toml",
|
|
32
|
+
sql: "sql"
|
|
33
|
+
};
|
|
34
|
+
const DOC_EXTS = new Set(["md", "mdx", "rst", "txt", "adoc"]);
|
|
35
|
+
const CONFIG_BASENAMES = new Set([
|
|
36
|
+
"package.json",
|
|
37
|
+
"tsconfig.json",
|
|
38
|
+
"vitest.config.ts",
|
|
39
|
+
"vitest.config.js",
|
|
40
|
+
"jest.config.js",
|
|
41
|
+
"jest.config.ts",
|
|
42
|
+
"playwright.config.ts",
|
|
43
|
+
"playwright.config.js",
|
|
44
|
+
"cypress.config.ts",
|
|
45
|
+
"cypress.config.js",
|
|
46
|
+
"pytest.ini",
|
|
47
|
+
"tox.ini",
|
|
48
|
+
"pyproject.toml",
|
|
49
|
+
"requirements.txt",
|
|
50
|
+
"setup.cfg",
|
|
51
|
+
"go.mod",
|
|
52
|
+
"cargo.toml",
|
|
53
|
+
"pom.xml",
|
|
54
|
+
"build.gradle",
|
|
55
|
+
"build.gradle.kts",
|
|
56
|
+
"gemfile",
|
|
57
|
+
"dockerfile",
|
|
58
|
+
".eslintrc.js",
|
|
59
|
+
".eslintrc.json",
|
|
60
|
+
"vite.config.ts",
|
|
61
|
+
"next.config.js",
|
|
62
|
+
"next.config.ts"
|
|
63
|
+
]);
|
|
64
|
+
export function extOf(relPath) {
|
|
65
|
+
const base = relPath.split("/").pop() || relPath;
|
|
66
|
+
const dot = base.lastIndexOf(".");
|
|
67
|
+
return dot >= 0 ? base.slice(dot + 1).toLowerCase() : "";
|
|
68
|
+
}
|
|
69
|
+
export function baseName(relPath) {
|
|
70
|
+
return (relPath.split("/").pop() || relPath).toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
export function languageOf(relPath) {
|
|
73
|
+
return LANGUAGE_BY_EXT[extOf(relPath)] || "other";
|
|
74
|
+
}
|
|
75
|
+
export function isTestFile(relPath) {
|
|
76
|
+
const p = relPath.toLowerCase();
|
|
77
|
+
const file = relPath.split("/").pop() || relPath;
|
|
78
|
+
return (/\.(test|spec)\.[a-z]+$/.test(p) ||
|
|
79
|
+
/(^|\/)test\.[cm]?[jt]sx?$/.test(p) ||
|
|
80
|
+
/(^|\/)__tests__\//.test(p) ||
|
|
81
|
+
/(^|\/)(tests?|e2e|cypress|spec)\//.test(p) ||
|
|
82
|
+
/(^|\/)(unit|integration|functional|acceptance)tests?\//.test(p) ||
|
|
83
|
+
/_test\.(py|go)$/.test(p) ||
|
|
84
|
+
/test_.*\.py$/.test(p) ||
|
|
85
|
+
// Deliberately original-case: FooTests.cs is a C# test; LoadTest.cs can be product code.
|
|
86
|
+
/Tests\.[cC][sS]$/.test(file) ||
|
|
87
|
+
// Deliberately original-case: UserTest.java is a test; Latest.java is product code.
|
|
88
|
+
/(?:^Test[A-Z0-9_].*|.*(?:Test|Tests|ITCase|TestCase)|.*[a-z0-9]IT)\.(java|kt)$/.test(file) ||
|
|
89
|
+
/(?:^test_[^/]+|[^/]+_(?:test|spec))\.rb$/i.test(file));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Test-support files (mocks, fixtures, helpers): legitimate IMPORTS targets but
|
|
93
|
+
* never TESTED_BY/COVERS-direction linkage targets — a test exercising its own
|
|
94
|
+
* helper is not evidence that production behavior is covered.
|
|
95
|
+
*/
|
|
96
|
+
export function isTestSupportPath(relPath) {
|
|
97
|
+
const p = relPath.toLowerCase();
|
|
98
|
+
const base = baseName(relPath);
|
|
99
|
+
return (/(^|\/)__mocks__\//.test(p) ||
|
|
100
|
+
/(^|\/)__fixtures__\//.test(p) ||
|
|
101
|
+
/(^|\/)(mocks?|fixtures?|testdata|testlib|mock[-_][^/]+|[^/]+[-_]mock)\//.test(p) ||
|
|
102
|
+
/(^|\/)testing\/[^/]*(mock|test(?:ing)?[-_]?utils?)/.test(p) ||
|
|
103
|
+
/\.(mocks?|fixtures?)\./.test(base) ||
|
|
104
|
+
/^testdata\.[a-z0-9]+$/.test(base) ||
|
|
105
|
+
/testlib\.[a-z0-9]+$/.test(base) ||
|
|
106
|
+
/-helpers?\.[a-z0-9]+$/.test(base) ||
|
|
107
|
+
/^test[-_]/.test(base) ||
|
|
108
|
+
/^testutils?\./.test(base));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Test infrastructure (e2e suites, fixtures, mocks, Playwright/Cypress libs).
|
|
112
|
+
* Broader than isTestFile (which classifies an individual test); this also
|
|
113
|
+
* catches `e2e-tests/`-style harness dirs whose helper CLASSES are role:"code"
|
|
114
|
+
* but whose methods are NOT production behavior. Used ONLY to keep such class
|
|
115
|
+
* methods out of the denominator — it does not reclassify the file.
|
|
116
|
+
*/
|
|
117
|
+
const TEST_INFRA_DIR = /(^|\/)(e2e[-_]?tests?|e2e|__tests__|tests?|cypress|playwright|specs?|__mocks__|fixtures|testdata|testlib|test[-_]?utils?|mock[-_][^/]+|[^/]+[-_]mock)\//i;
|
|
118
|
+
export function isTestInfraPath(relPath) {
|
|
119
|
+
return TEST_INFRA_DIR.test(relPath) || isTestSupportPath(relPath);
|
|
120
|
+
}
|
|
121
|
+
/** Plain-language reason a non-product symbol is excluded from the behavior denominator. */
|
|
122
|
+
export const NON_PRODUCT_REASON = "CI/test-infra path (.github, e2e/cypress/playwright suite, mocks/fixtures/testdata/testlib) — not product behavior.";
|
|
123
|
+
export const GENERATED_CODE_REASON = "Generated code (Code generated ... DO NOT EDIT) — not product-authored behavior.";
|
|
124
|
+
export function isGeneratedCode(content) {
|
|
125
|
+
const lines = content.slice(0, 8192).split(/\r?\n/);
|
|
126
|
+
for (let i = 0; i < lines.length; i++) {
|
|
127
|
+
const line = lines[i].trim();
|
|
128
|
+
if (!line)
|
|
129
|
+
continue;
|
|
130
|
+
const isComment = /^(?:\/\/|#|\/\*|\*)/.test(line);
|
|
131
|
+
if (isComment) {
|
|
132
|
+
if (/\bCode generated\b/.test(line)) {
|
|
133
|
+
const window = lines
|
|
134
|
+
.slice(i, Math.min(lines.length, i + 6))
|
|
135
|
+
.map((l) => l.trim())
|
|
136
|
+
.filter((l) => !l || /^(?:\/\/|#|\/\*|\*)/.test(l))
|
|
137
|
+
.join("\n");
|
|
138
|
+
if (/DO NOT EDIT\.?/.test(window))
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// Go generated markers normally sit before `package`; some generators put the
|
|
144
|
+
// marker immediately after it. Once real declarations/imports begin, a marker
|
|
145
|
+
// may be part of a template string and must not classify the source file.
|
|
146
|
+
if (/^package\s+[A-Za-z_][A-Za-z0-9_]*\s*;?$/.test(line))
|
|
147
|
+
continue;
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const CI_DIR = /(^|\/)\.github(\/|$)/;
|
|
153
|
+
// Test-artifact DIRECTORIES anywhere — fixtures, generated mocks, Go testdata,
|
|
154
|
+
// and explicit test support libraries. Matches a directory segment only.
|
|
155
|
+
const INFRA_DIR_ANYWHERE = /(^|\/)(__mocks__|__fixtures__|__tests__|mocks?|fixtures?|testdata|testlib|mock[-_][^/]+|[^/]+[-_]mock)\//;
|
|
156
|
+
// Test-support library files outside a dedicated test-support directory, e.g.
|
|
157
|
+
// Mattermost's api4/apitestlib.go and cmd/mattermost/commands/cmdtestlib.go.
|
|
158
|
+
const TESTLIB_FILE = /(^|\/)[^/]*testlib\.[a-z0-9]+$/i;
|
|
159
|
+
const TESTDATA_FILE = /(^|\/)testdata\.[a-z0-9]+$/i;
|
|
160
|
+
const TESTING_SUPPORT_FILE = /(^|\/)testing\/[^/]*(mock|test(?:ing)?[-_]?utils?)[^/]*\.[a-z0-9]+$/i;
|
|
161
|
+
// A test SUITE at the repo ROOT (first path segment). A `playwright`/`cypress`/`e2e`
|
|
162
|
+
// segment NESTED under product code (e.g. `src/playwright/`) is NOT matched.
|
|
163
|
+
const ROOT_TEST_SUITE = /^(?:\.\/)?(e2e|e2e[-_]tests?|cypress|playwright|tests?|specs?)\//;
|
|
164
|
+
/**
|
|
165
|
+
* A path whose exported symbols are NOT product behavior, for DENOMINATOR
|
|
166
|
+
* exclusion. Deliberately STRICTER than `isTestInfraPath` (which is a permissive
|
|
167
|
+
* test-SUPPORT-import detector): it requires real test/CI context, so product
|
|
168
|
+
* code that merely integrates Playwright (`src/playwright/runner.ts`) or has a
|
|
169
|
+
* helper-suffixed filename (`src/date-helper.ts`) is NOT excluded — over-exclusion
|
|
170
|
+
* would falsely raise coverage. No broad `scripts/`/`tools/` exclusion. Symbols
|
|
171
|
+
* here are kept as nodes with denominator_eligible:false + a reason.
|
|
172
|
+
*/
|
|
173
|
+
export function isNonProductPath(relPath) {
|
|
174
|
+
return (CI_DIR.test(relPath) ||
|
|
175
|
+
INFRA_DIR_ANYWHERE.test(relPath) ||
|
|
176
|
+
TESTLIB_FILE.test(relPath) ||
|
|
177
|
+
TESTDATA_FILE.test(relPath) ||
|
|
178
|
+
TESTING_SUPPORT_FILE.test(relPath) ||
|
|
179
|
+
ROOT_TEST_SUITE.test(relPath));
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Path/name-only test-layer hint. Coarse on purpose — a LOW-confidence
|
|
183
|
+
* corroborator for the AST classifier (testLayer.ts), never the authority.
|
|
184
|
+
* Returns "unknown" when no path signal matches: a path alone NEVER asserts
|
|
185
|
+
* `unit` (the old catch-all), because that would fold unclassifiable e2e/api
|
|
186
|
+
* behaviors into the structural confirmed-%.
|
|
187
|
+
*/
|
|
188
|
+
export function testLayerOf(relPath) {
|
|
189
|
+
const p = relPath.toLowerCase();
|
|
190
|
+
if (/(^|\/)(e2e)\//.test(p) || /\.e2e\./.test(p) || p.includes("playwright") || p.includes("cypress"))
|
|
191
|
+
return "e2e";
|
|
192
|
+
if (/(^|\/)integration\//.test(p) || /\.integration\./.test(p) || /\.int\./.test(p))
|
|
193
|
+
return "integration";
|
|
194
|
+
if (/(^|\/)(api)\//.test(p) || /\.api\./.test(p))
|
|
195
|
+
return "api";
|
|
196
|
+
if (/\.(component|comp)\./.test(p) || p.includes("/components/"))
|
|
197
|
+
return "component";
|
|
198
|
+
if (/(^|\/)(unit)\//.test(p) || /\.(unit)\./.test(p))
|
|
199
|
+
return "unit";
|
|
200
|
+
return "unknown";
|
|
201
|
+
}
|
|
202
|
+
export function roleOf(relPath) {
|
|
203
|
+
const base = baseName(relPath);
|
|
204
|
+
const ext = extOf(relPath);
|
|
205
|
+
if (isTestFile(relPath))
|
|
206
|
+
return "test";
|
|
207
|
+
if (CONFIG_BASENAMES.has(base) || /\.config\.[cm]?[jt]s$/.test(base))
|
|
208
|
+
return "config";
|
|
209
|
+
if (DOC_EXTS.has(ext))
|
|
210
|
+
return "doc";
|
|
211
|
+
if (LANGUAGE_BY_EXT[ext] && ext !== "md" && ext !== "json" && ext !== "yaml" && ext !== "yml" && ext !== "toml") {
|
|
212
|
+
return "code";
|
|
213
|
+
}
|
|
214
|
+
return "other";
|
|
215
|
+
}
|
|
216
|
+
export function manifestKindOf(relPath) {
|
|
217
|
+
const role = roleOf(relPath);
|
|
218
|
+
return role;
|
|
219
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { shortHash } from "../util/hash.js";
|
|
3
|
+
const MAX_EMITTED_CLUSTERS = 50;
|
|
4
|
+
const MAX_CLUSTER_ITEMS = 8;
|
|
5
|
+
const MAX_COMPONENT_SIZE = 250;
|
|
6
|
+
export function buildCallsAdjacency(nodes, edges, opts = {}) {
|
|
7
|
+
const symbolIds = new Set(nodes
|
|
8
|
+
.filter((n) => n.kind === "CodeSymbol" &&
|
|
9
|
+
n.stale !== true &&
|
|
10
|
+
(!opts.denominatorEligibleOnly || n.denominator_eligible === true))
|
|
11
|
+
.map((n) => n.external_id));
|
|
12
|
+
const adjacency = new Map();
|
|
13
|
+
for (const e of edges) {
|
|
14
|
+
if (e.relationship_type !== "CALLS")
|
|
15
|
+
continue;
|
|
16
|
+
if (e.evidence_strength !== "hard" && e.evidence_strength !== "framework-derived")
|
|
17
|
+
continue;
|
|
18
|
+
if (!symbolIds.has(e.from_external_id) || !symbolIds.has(e.to_external_id))
|
|
19
|
+
continue;
|
|
20
|
+
const item = {
|
|
21
|
+
from: e.from_external_id,
|
|
22
|
+
to: e.to_external_id,
|
|
23
|
+
evidence_strength: e.evidence_strength,
|
|
24
|
+
resolution: typeof e.properties?.resolution === "string" ? e.properties.resolution : undefined
|
|
25
|
+
};
|
|
26
|
+
const list = adjacency.get(item.from);
|
|
27
|
+
if (list)
|
|
28
|
+
list.push(item);
|
|
29
|
+
else
|
|
30
|
+
adjacency.set(item.from, [item]);
|
|
31
|
+
}
|
|
32
|
+
for (const list of adjacency.values()) {
|
|
33
|
+
list.sort((a, b) => a.to.localeCompare(b.to) ||
|
|
34
|
+
a.evidence_strength.localeCompare(b.evidence_strength) ||
|
|
35
|
+
(a.resolution ?? "").localeCompare(b.resolution ?? ""));
|
|
36
|
+
}
|
|
37
|
+
return adjacency;
|
|
38
|
+
}
|
|
39
|
+
class DisjointSet {
|
|
40
|
+
parent = new Map();
|
|
41
|
+
rank = new Map();
|
|
42
|
+
add(id) {
|
|
43
|
+
if (this.parent.has(id))
|
|
44
|
+
return;
|
|
45
|
+
this.parent.set(id, id);
|
|
46
|
+
this.rank.set(id, 0);
|
|
47
|
+
}
|
|
48
|
+
find(id) {
|
|
49
|
+
const p = this.parent.get(id);
|
|
50
|
+
if (!p) {
|
|
51
|
+
this.add(id);
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
if (p === id)
|
|
55
|
+
return id;
|
|
56
|
+
const root = this.find(p);
|
|
57
|
+
this.parent.set(id, root);
|
|
58
|
+
return root;
|
|
59
|
+
}
|
|
60
|
+
union(a, b) {
|
|
61
|
+
let ra = this.find(a);
|
|
62
|
+
let rb = this.find(b);
|
|
63
|
+
if (ra === rb)
|
|
64
|
+
return;
|
|
65
|
+
const rankA = this.rank.get(ra) ?? 0;
|
|
66
|
+
const rankB = this.rank.get(rb) ?? 0;
|
|
67
|
+
if (rankA < rankB || (rankA === rankB && ra > rb)) {
|
|
68
|
+
[ra, rb] = [rb, ra];
|
|
69
|
+
}
|
|
70
|
+
this.parent.set(rb, ra);
|
|
71
|
+
if (rankA === rankB)
|
|
72
|
+
this.rank.set(ra, rankA + 1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function fileOfSymbol(n) {
|
|
76
|
+
const f = n.properties.file;
|
|
77
|
+
return typeof f === "string" ? f : null;
|
|
78
|
+
}
|
|
79
|
+
function languageOfFile(filesById, file) {
|
|
80
|
+
const lang = filesById.get(file)?.properties.language;
|
|
81
|
+
return typeof lang === "string" ? lang : "unknown";
|
|
82
|
+
}
|
|
83
|
+
function dirOf(file) {
|
|
84
|
+
const d = path.posix.dirname(file);
|
|
85
|
+
return d === "." ? "" : d;
|
|
86
|
+
}
|
|
87
|
+
function localImportPair(fromFile, toFile) {
|
|
88
|
+
const fromDir = dirOf(fromFile);
|
|
89
|
+
const toDir = dirOf(toFile);
|
|
90
|
+
if (!fromDir || !toDir)
|
|
91
|
+
return fromDir === toDir;
|
|
92
|
+
return fromDir === toDir || fromDir.startsWith(`${toDir}/`) || toDir.startsWith(`${fromDir}/`);
|
|
93
|
+
}
|
|
94
|
+
function titleFromFiles(files) {
|
|
95
|
+
const dirs = files.map(dirOf).filter(Boolean).sort();
|
|
96
|
+
if (dirs.length === 0)
|
|
97
|
+
return path.posix.basename(files[0] ?? "root");
|
|
98
|
+
const split = dirs.map((d) => d.split("/"));
|
|
99
|
+
const prefix = [];
|
|
100
|
+
for (let i = 0; i < split[0].length; i++) {
|
|
101
|
+
const part = split[0][i];
|
|
102
|
+
if (split.every((s) => s[i] === part))
|
|
103
|
+
prefix.push(part);
|
|
104
|
+
else
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
const labelPath = prefix.length > 0 ? prefix.join("/") : dirs[0];
|
|
108
|
+
const last = labelPath.split("/").filter(Boolean).pop() ?? labelPath;
|
|
109
|
+
return last === labelPath ? labelPath : `${last} (${labelPath})`;
|
|
110
|
+
}
|
|
111
|
+
function moduleKey(file) {
|
|
112
|
+
const dir = dirOf(file);
|
|
113
|
+
if (!dir)
|
|
114
|
+
return "(root)";
|
|
115
|
+
const parts = dir.split("/").filter(Boolean);
|
|
116
|
+
const srcIdx = parts.lastIndexOf("src");
|
|
117
|
+
if (srcIdx >= 0)
|
|
118
|
+
return parts.slice(0, Math.min(parts.length, srcIdx + 3)).join("/");
|
|
119
|
+
const javaIdx = parts.lastIndexOf("java");
|
|
120
|
+
if (javaIdx >= 0)
|
|
121
|
+
return parts.slice(0, Math.min(parts.length, javaIdx + 3)).join("/");
|
|
122
|
+
const pyIdx = parts.lastIndexOf("python");
|
|
123
|
+
if (pyIdx >= 0)
|
|
124
|
+
return parts.slice(0, Math.min(parts.length, pyIdx + 3)).join("/");
|
|
125
|
+
return parts.slice(0, Math.min(parts.length, 4)).join("/");
|
|
126
|
+
}
|
|
127
|
+
function pathPrefix(file, depth) {
|
|
128
|
+
const dir = dirOf(file);
|
|
129
|
+
if (!dir)
|
|
130
|
+
return "(root)";
|
|
131
|
+
const parts = dir.split("/").filter(Boolean);
|
|
132
|
+
return parts.slice(0, Math.min(parts.length, depth)).join("/") || "(root)";
|
|
133
|
+
}
|
|
134
|
+
function splitOversizedGroup(ids, symbolById, depth = 5) {
|
|
135
|
+
if (ids.length <= MAX_COMPONENT_SIZE)
|
|
136
|
+
return [ids];
|
|
137
|
+
const byPrefix = new Map();
|
|
138
|
+
for (const id of ids) {
|
|
139
|
+
const file = fileOfSymbol(symbolById.get(id));
|
|
140
|
+
const key = file ? pathPrefix(file, depth) : "(unknown)";
|
|
141
|
+
const list = byPrefix.get(key);
|
|
142
|
+
if (list)
|
|
143
|
+
list.push(id);
|
|
144
|
+
else
|
|
145
|
+
byPrefix.set(key, [id]);
|
|
146
|
+
}
|
|
147
|
+
if (byPrefix.size <= 1) {
|
|
148
|
+
const maxDepth = Math.max(...ids.map((id) => {
|
|
149
|
+
const file = fileOfSymbol(symbolById.get(id));
|
|
150
|
+
return file ? dirOf(file).split("/").filter(Boolean).length : 0;
|
|
151
|
+
}));
|
|
152
|
+
if (depth <= maxDepth)
|
|
153
|
+
return splitOversizedGroup(ids, symbolById, depth + 1);
|
|
154
|
+
const byFile = new Map();
|
|
155
|
+
for (const id of ids) {
|
|
156
|
+
const key = fileOfSymbol(symbolById.get(id)) ?? "(unknown)";
|
|
157
|
+
const list = byFile.get(key);
|
|
158
|
+
if (list)
|
|
159
|
+
list.push(id);
|
|
160
|
+
else
|
|
161
|
+
byFile.set(key, [id]);
|
|
162
|
+
}
|
|
163
|
+
if (byFile.size > 1)
|
|
164
|
+
return [...byFile.values()].map((chunk) => chunk.sort());
|
|
165
|
+
return [ids];
|
|
166
|
+
}
|
|
167
|
+
return [...byPrefix.values()].flatMap((chunk) => splitOversizedGroup(chunk.sort(), symbolById, depth + 1));
|
|
168
|
+
}
|
|
169
|
+
function addCount(map, key, inc = 1) {
|
|
170
|
+
map.set(key, (map.get(key) ?? 0) + inc);
|
|
171
|
+
}
|
|
172
|
+
function sortedCounts(map) {
|
|
173
|
+
return Object.fromEntries([...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])));
|
|
174
|
+
}
|
|
175
|
+
function stableTop(items, limit = MAX_CLUSTER_ITEMS) {
|
|
176
|
+
return [...items].sort().slice(0, limit);
|
|
177
|
+
}
|
|
178
|
+
export function buildStructuralClusters(nodes, edges, candidateEdges) {
|
|
179
|
+
const filesById = new Map(nodes.filter((n) => n.kind === "File").map((n) => [n.external_id, n]));
|
|
180
|
+
const symbols = nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && n.stale !== true);
|
|
181
|
+
const symbolIds = new Set(symbols.map((n) => n.external_id));
|
|
182
|
+
const symbolById = new Map(symbols.map((n) => [n.external_id, n]));
|
|
183
|
+
const symbolsByFile = new Map();
|
|
184
|
+
for (const n of symbols) {
|
|
185
|
+
const file = fileOfSymbol(n);
|
|
186
|
+
if (!file)
|
|
187
|
+
continue;
|
|
188
|
+
const list = symbolsByFile.get(file);
|
|
189
|
+
if (list)
|
|
190
|
+
list.push(n.external_id);
|
|
191
|
+
else
|
|
192
|
+
symbolsByFile.set(file, [n.external_id]);
|
|
193
|
+
}
|
|
194
|
+
for (const ids of symbolsByFile.values())
|
|
195
|
+
ids.sort();
|
|
196
|
+
const dsu = new DisjointSet();
|
|
197
|
+
for (const id of symbolIds)
|
|
198
|
+
dsu.add(id);
|
|
199
|
+
// A file is the smallest structural container. Grouping its emitted behavior
|
|
200
|
+
// symbols prevents one class/file from splintering before graph edges apply.
|
|
201
|
+
for (const ids of symbolsByFile.values()) {
|
|
202
|
+
const [first, ...rest] = ids;
|
|
203
|
+
if (!first)
|
|
204
|
+
continue;
|
|
205
|
+
for (const id of rest)
|
|
206
|
+
dsu.union(first, id);
|
|
207
|
+
}
|
|
208
|
+
const links = [];
|
|
209
|
+
let hardCallEdges = 0;
|
|
210
|
+
let likelyCallEdges = 0;
|
|
211
|
+
let importEdgesConsidered = 0;
|
|
212
|
+
let importEdgesUsed = 0;
|
|
213
|
+
const importFanIn = new Map();
|
|
214
|
+
for (const e of edges) {
|
|
215
|
+
if (e.relationship_type !== "IMPORTS")
|
|
216
|
+
continue;
|
|
217
|
+
if (!symbolsByFile.has(e.from_external_id) || !symbolsByFile.has(e.to_external_id))
|
|
218
|
+
continue;
|
|
219
|
+
addCount(importFanIn, e.to_external_id);
|
|
220
|
+
}
|
|
221
|
+
const productFiles = symbolsByFile.size;
|
|
222
|
+
const importHubThreshold = Math.max(25, Math.ceil(productFiles * 0.02));
|
|
223
|
+
for (const [, outgoing] of buildCallsAdjacency(nodes, edges, { denominatorEligibleOnly: true })) {
|
|
224
|
+
for (const e of outgoing) {
|
|
225
|
+
hardCallEdges++;
|
|
226
|
+
links.push({ from: e.from, to: e.to, kind: "hard_call" });
|
|
227
|
+
dsu.union(e.from, e.to);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const e of edges) {
|
|
231
|
+
if (e.relationship_type === "IMPORTS") {
|
|
232
|
+
const fromSyms = symbolsByFile.get(e.from_external_id);
|
|
233
|
+
const toSyms = symbolsByFile.get(e.to_external_id);
|
|
234
|
+
if (!fromSyms || !toSyms || e.from_external_id === e.to_external_id)
|
|
235
|
+
continue;
|
|
236
|
+
importEdgesConsidered++;
|
|
237
|
+
if ((importFanIn.get(e.to_external_id) ?? 0) > importHubThreshold)
|
|
238
|
+
continue;
|
|
239
|
+
if (!localImportPair(e.from_external_id, e.to_external_id))
|
|
240
|
+
continue;
|
|
241
|
+
const from = fromSyms[0];
|
|
242
|
+
const to = toSyms[0];
|
|
243
|
+
if (!from || !to || from === to)
|
|
244
|
+
continue;
|
|
245
|
+
importEdgesUsed++;
|
|
246
|
+
links.push({ from, to, kind: "import" });
|
|
247
|
+
dsu.union(from, to);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const e of candidateEdges) {
|
|
251
|
+
if (e.relationship_type !== "MAY_CALL")
|
|
252
|
+
continue;
|
|
253
|
+
if (!symbolIds.has(e.from_external_id) || !symbolIds.has(e.to_external_id))
|
|
254
|
+
continue;
|
|
255
|
+
likelyCallEdges++;
|
|
256
|
+
links.push({ from: e.from_external_id, to: e.to_external_id, kind: "likely_call" });
|
|
257
|
+
dsu.union(e.from_external_id, e.to_external_id);
|
|
258
|
+
}
|
|
259
|
+
const byRoot = new Map();
|
|
260
|
+
for (const id of symbolIds) {
|
|
261
|
+
const root = dsu.find(id);
|
|
262
|
+
const list = byRoot.get(root);
|
|
263
|
+
if (list)
|
|
264
|
+
list.push(id);
|
|
265
|
+
else
|
|
266
|
+
byRoot.set(root, [id]);
|
|
267
|
+
}
|
|
268
|
+
const groups = [];
|
|
269
|
+
for (const ids of byRoot.values()) {
|
|
270
|
+
ids.sort();
|
|
271
|
+
if (ids.length <= MAX_COMPONENT_SIZE) {
|
|
272
|
+
groups.push(ids);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const byModule = new Map();
|
|
276
|
+
for (const id of ids) {
|
|
277
|
+
const file = fileOfSymbol(symbolById.get(id));
|
|
278
|
+
const key = file ? moduleKey(file) : "(unknown)";
|
|
279
|
+
const list = byModule.get(key);
|
|
280
|
+
if (list)
|
|
281
|
+
list.push(id);
|
|
282
|
+
else
|
|
283
|
+
byModule.set(key, [id]);
|
|
284
|
+
}
|
|
285
|
+
for (const chunk of byModule.values()) {
|
|
286
|
+
chunk.sort();
|
|
287
|
+
groups.push(...splitOversizedGroup(chunk, symbolById));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const groupBySymbol = new Map();
|
|
291
|
+
groups.forEach((ids, i) => {
|
|
292
|
+
for (const id of ids)
|
|
293
|
+
groupBySymbol.set(id, i);
|
|
294
|
+
});
|
|
295
|
+
const linkCounts = new Map();
|
|
296
|
+
for (const l of links) {
|
|
297
|
+
const a = groupBySymbol.get(l.from);
|
|
298
|
+
const b = groupBySymbol.get(l.to);
|
|
299
|
+
if (a == null || b == null || a !== b)
|
|
300
|
+
continue;
|
|
301
|
+
const counts = linkCounts.get(a) ?? { hard_calls: 0, likely_calls: 0, import_links: 0 };
|
|
302
|
+
if (l.kind === "hard_call")
|
|
303
|
+
counts.hard_calls++;
|
|
304
|
+
else if (l.kind === "likely_call")
|
|
305
|
+
counts.likely_calls++;
|
|
306
|
+
else
|
|
307
|
+
counts.import_links++;
|
|
308
|
+
linkCounts.set(a, counts);
|
|
309
|
+
}
|
|
310
|
+
const rawClusters = groups
|
|
311
|
+
.map((ids, groupIndex) => {
|
|
312
|
+
ids.sort();
|
|
313
|
+
const files = new Set();
|
|
314
|
+
const languages = new Map();
|
|
315
|
+
for (const id of ids) {
|
|
316
|
+
const file = fileOfSymbol(symbolById.get(id));
|
|
317
|
+
if (!file)
|
|
318
|
+
continue;
|
|
319
|
+
files.add(file);
|
|
320
|
+
addCount(languages, languageOfFile(filesById, file));
|
|
321
|
+
}
|
|
322
|
+
const sortedFiles = stableTop(files);
|
|
323
|
+
const counts = linkCounts.get(groupIndex) ?? { hard_calls: 0, likely_calls: 0, import_links: 0 };
|
|
324
|
+
return {
|
|
325
|
+
id: `cluster:${shortHash(ids.join("|"))}`,
|
|
326
|
+
title: titleFromFiles([...files].sort()),
|
|
327
|
+
size: ids.length,
|
|
328
|
+
files: files.size,
|
|
329
|
+
all_files: [...files].sort(),
|
|
330
|
+
languages: sortedCounts(languages),
|
|
331
|
+
top_files: sortedFiles,
|
|
332
|
+
top_symbols: stableTop(ids),
|
|
333
|
+
...counts
|
|
334
|
+
};
|
|
335
|
+
})
|
|
336
|
+
.filter((c) => c.size >= 2)
|
|
337
|
+
.sort((a, b) => b.size - a.size ||
|
|
338
|
+
b.hard_calls + b.likely_calls + b.import_links - (a.hard_calls + a.likely_calls + a.import_links) ||
|
|
339
|
+
a.id.localeCompare(b.id));
|
|
340
|
+
const clusteredSymbols = rawClusters.reduce((sum, c) => sum + c.size, 0);
|
|
341
|
+
const clusteredFiles = new Set(rawClusters.flatMap((c) => c.all_files)).size;
|
|
342
|
+
const clusters = rawClusters.map(({ all_files, ...c }) => c);
|
|
343
|
+
const emitted = clusters.slice(0, MAX_EMITTED_CLUSTERS);
|
|
344
|
+
return {
|
|
345
|
+
method: "deterministic_components_with_path_splits",
|
|
346
|
+
total_clusters: clusters.length,
|
|
347
|
+
emitted_clusters: emitted.length,
|
|
348
|
+
clustered_symbols: clusteredSymbols,
|
|
349
|
+
clustered_files: clusteredFiles,
|
|
350
|
+
hard_call_edges: hardCallEdges,
|
|
351
|
+
likely_call_edges: likelyCallEdges,
|
|
352
|
+
import_edges_considered: importEdgesConsidered,
|
|
353
|
+
import_edges_used: importEdgesUsed,
|
|
354
|
+
import_hub_threshold: importHubThreshold,
|
|
355
|
+
clusters: emitted
|
|
356
|
+
};
|
|
357
|
+
}
|