@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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +328 -0
  3. package/dist/local/agentWorkflow.js +81 -0
  4. package/dist/local/aiGraph/links.js +635 -0
  5. package/dist/local/analyze/analyzer.js +2129 -0
  6. package/dist/local/analyze/behaviorContracts.js +169 -0
  7. package/dist/local/analyze/boilerplate.js +42 -0
  8. package/dist/local/analyze/callGraph.js +458 -0
  9. package/dist/local/analyze/classify.js +219 -0
  10. package/dist/local/analyze/clustering.js +357 -0
  11. package/dist/local/analyze/confirm.js +2422 -0
  12. package/dist/local/analyze/coverage.js +518 -0
  13. package/dist/local/analyze/coverageArtifacts.js +607 -0
  14. package/dist/local/analyze/frameworks.js +115 -0
  15. package/dist/local/analyze/linkage/conventions.js +160 -0
  16. package/dist/local/analyze/parseCache.js +164 -0
  17. package/dist/local/analyze/selfAssert.js +53 -0
  18. package/dist/local/analyze/symbols.js +430 -0
  19. package/dist/local/analyze/testLayer.js +135 -0
  20. package/dist/local/analyze/treeSitter/engine.js +1253 -0
  21. package/dist/local/analyze/treeSitter/languages.js +101 -0
  22. package/dist/local/autoProve.js +620 -0
  23. package/dist/local/cli.js +1468 -0
  24. package/dist/local/cliArgs.js +112 -0
  25. package/dist/local/corpusScope.js +162 -0
  26. package/dist/local/enrich/csv.js +348 -0
  27. package/dist/local/enrich/index.js +43 -0
  28. package/dist/local/enrich/markdown.js +193 -0
  29. package/dist/local/explain/explain.js +91 -0
  30. package/dist/local/exportCli.js +26 -0
  31. package/dist/local/flows/flowWalker.js +215 -0
  32. package/dist/local/flows/llmFlowDiscovery.js +567 -0
  33. package/dist/local/freshness/changed.js +280 -0
  34. package/dist/local/freshness/manifest.js +35 -0
  35. package/dist/local/freshness/status.js +30 -0
  36. package/dist/local/gaps/gaps.js +114 -0
  37. package/dist/local/generate/buckets.js +73 -0
  38. package/dist/local/generate/compareJudge.js +124 -0
  39. package/dist/local/generate/compareReport.js +538 -0
  40. package/dist/local/generate/compareScore.js +105 -0
  41. package/dist/local/generate/deriveImports.js +91 -0
  42. package/dist/local/generate/generator.js +2586 -0
  43. package/dist/local/generate/prompt.js +144 -0
  44. package/dist/local/generate/promptV5.js +438 -0
  45. package/dist/local/generate/providers.js +400 -0
  46. package/dist/local/generate/runHints.js +304 -0
  47. package/dist/local/graph/citations.js +73 -0
  48. package/dist/local/graph/confirmable.js +72 -0
  49. package/dist/local/graph/factories.js +210 -0
  50. package/dist/local/graph/ontology.js +18 -0
  51. package/dist/local/interactive.js +53 -0
  52. package/dist/local/jobs/jobStore.js +80 -0
  53. package/dist/local/jobs/notify.js +29 -0
  54. package/dist/local/jobs/runner.js +75 -0
  55. package/dist/local/ledger.js +117 -0
  56. package/dist/local/localConfig.js +112 -0
  57. package/dist/local/mcp.js +548 -0
  58. package/dist/local/operations.js +1749 -0
  59. package/dist/local/pack/coverageReport.js +192 -0
  60. package/dist/local/pack/exporter.js +195 -0
  61. package/dist/local/pack/schema.js +128 -0
  62. package/dist/local/pack/summary.js +127 -0
  63. package/dist/local/pack/validate.js +25 -0
  64. package/dist/local/proofRunnability.js +366 -0
  65. package/dist/local/recipe/dbSqljs.js +255 -0
  66. package/dist/local/reprove/paths.js +13 -0
  67. package/dist/local/reprove/scoped.js +136 -0
  68. package/dist/local/resolve/barrelWalker.js +178 -0
  69. package/dist/local/resolve/exportIndex.js +270 -0
  70. package/dist/local/resolve/importGraph.js +347 -0
  71. package/dist/local/resolve/resolver.js +122 -0
  72. package/dist/local/resolve/resolverCache.js +117 -0
  73. package/dist/local/rtm.js +413 -0
  74. package/dist/local/score/coverage.js +99 -0
  75. package/dist/local/score/doctor.js +67 -0
  76. package/dist/local/score/risk.js +362 -0
  77. package/dist/local/score/score.js +182 -0
  78. package/dist/local/types.js +1 -0
  79. package/dist/local/util/hash.js +16 -0
  80. package/dist/local/util/ids.js +16 -0
  81. package/dist/local/util/progress.js +8 -0
  82. package/dist/local/util/redact.js +39 -0
  83. package/dist/local/util/time.js +1 -0
  84. package/dist/local/util/walk.js +174 -0
  85. package/dist/local/viz/behaviorReportData.js +367 -0
  86. package/dist/local/viz/behaviorReportHtml.js +664 -0
  87. package/dist/local/viz/d3.bundle.js +3 -0
  88. package/dist/local/viz/html.js +1152 -0
  89. package/dist/local/viz/payload.js +525 -0
  90. package/dist/local/workspace.js +99 -0
  91. package/docs/agent-workflow.md +167 -0
  92. package/docs/agents/claude-code.md +43 -0
  93. package/docs/agents/codex.md +52 -0
  94. package/docs/agents/cursor.md +39 -0
  95. package/docs/agents/opencode.md +43 -0
  96. package/docs/agents/vscode.md +34 -0
  97. package/docs/local-proof-kit.md +269 -0
  98. package/package.json +92 -0
  99. package/scripts/spikes/dynamic-proof-jest-reporter.cjs +66 -0
  100. package/scripts/spikes/dynamic-proof-mocha-reporter.cjs +105 -0
  101. package/scripts/spikes/dynamic-proof-spike.mjs +2335 -0
  102. package/scripts/spikes/dynamic-proof-vitest-reporter.mjs +81 -0
  103. 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
+ }