@sean.holung/minicode 0.3.2 → 0.3.3
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 +48 -43
- package/dist/scripts/run-benchmarks.js +147 -0
- package/dist/src/agent/config.js +149 -40
- package/dist/src/agent/editable-config.js +314 -0
- package/dist/src/analysis/structural-analysis.js +379 -0
- package/dist/src/benchmark/evaluator.js +79 -0
- package/dist/src/benchmark/index.js +4 -0
- package/dist/src/benchmark/reporter.js +177 -0
- package/dist/src/benchmark/runner.js +100 -0
- package/dist/src/benchmark/task-loader.js +78 -0
- package/dist/src/benchmark/types.js +5 -0
- package/dist/src/cli/args.js +10 -0
- package/dist/src/cli/config-slash-command.js +135 -0
- package/dist/src/cli/plugin-install.js +69 -0
- package/dist/src/index.js +76 -6
- package/dist/src/indexer/cache.js +6 -4
- package/dist/src/indexer/code-map.js +41 -13
- package/dist/src/indexer/plugins/typescript.js +70 -23
- package/dist/src/indexer/project-index.js +175 -36
- package/dist/src/indexer/symbol-names.js +92 -0
- package/dist/src/model-utils.js +18 -0
- package/dist/src/serve/agent-bridge.js +203 -24
- package/dist/src/serve/mcp-server.js +405 -0
- package/dist/src/serve/server.js +165 -10
- package/dist/src/serve/websocket.js +8 -0
- package/dist/src/shared/graph-styles.js +119 -0
- package/dist/src/tools/find-path.js +75 -0
- package/dist/src/tools/find-references.js +7 -2
- package/dist/src/tools/get-dependencies.js +3 -2
- package/dist/src/tools/read-symbol.js +12 -5
- package/dist/src/tools/registry.js +3 -1
- package/dist/src/tools/search-code-map.js +4 -2
- package/dist/src/ui/app.js +1 -1
- package/dist/src/ui/cli-ink.js +79 -4
- package/dist/src/ui/components/header-bar.js +6 -2
- package/dist/src/ui/state/ui-store.js +5 -0
- package/dist/src/web/app.js +1124 -176
- package/dist/src/web/index.html +113 -3
- package/dist/src/web/style.css +973 -55
- package/dist/tests/agent.test.js +31 -0
- package/dist/tests/analysis-helpers.test.js +89 -0
- package/dist/tests/analysis-ui.test.js +29 -0
- package/dist/tests/benchmark-harness.test.js +527 -0
- package/dist/tests/config-api.test.js +143 -0
- package/dist/tests/config-integration.test.js +751 -0
- package/dist/tests/config-slash-command.test.js +106 -0
- package/dist/tests/config.test.js +42 -1
- package/dist/tests/context-indicator.test.js +220 -0
- package/dist/tests/editable-config.test.js +109 -0
- package/dist/tests/find-path.test.js +183 -0
- package/dist/tests/focus-tracker.test.js +62 -0
- package/dist/tests/graph-onboarding.test.js +55 -0
- package/dist/tests/graph-styles.test.js +65 -0
- package/dist/tests/indexer.test.js +137 -0
- package/dist/tests/mcp-and-plugin.test.js +186 -0
- package/dist/tests/model-client-openai.test.js +29 -0
- package/dist/tests/model-selection.test.js +136 -0
- package/dist/tests/model-utils.test.js +22 -0
- package/dist/tests/reasoning-effort.test.js +264 -0
- package/dist/tests/run-benchmarks.test.js +161 -0
- package/dist/tests/search-code-map.test.js +18 -0
- package/dist/tests/serve.integration.test.js +218 -2
- package/dist/tests/session-ui.test.js +21 -0
- package/dist/tests/session.test.js +50 -0
- package/dist/tests/settings-ui.test.js +30 -0
- package/dist/tests/structural-analysis.test.js +218 -0
- package/node_modules/@minicode/agent-sdk/README.md +80 -51
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts +16 -5
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +51 -33
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +14 -0
- package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +3 -2
- package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/index.js +2 -0
- package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts +35 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts.map +1 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js +64 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js.map +1 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts +7 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts +5 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +83 -11
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts +1 -0
- package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js +8 -1
- package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.js +4 -1
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js +3 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js +8 -2
- package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -5
- package/plugin/.claude-plugin/plugin.json +12 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/CLAUDE.md +26 -0
- package/plugin/skills/analyze/SKILL.md +12 -0
- package/plugin/skills/focus/SKILL.md +20 -0
- package/plugin/skills/graph/SKILL.md +13 -0
- package/plugin/skills/symbols/SKILL.md +13 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadConfigFile, MINICODE_HOME, resolveConfigEnv, } from "./config.js";
|
|
4
|
+
const REASONING_VALUES = [
|
|
5
|
+
"xhigh",
|
|
6
|
+
"high",
|
|
7
|
+
"medium",
|
|
8
|
+
"low",
|
|
9
|
+
"minimal",
|
|
10
|
+
"none",
|
|
11
|
+
];
|
|
12
|
+
export const EDITABLE_CONFIG_DEFINITIONS = [
|
|
13
|
+
{
|
|
14
|
+
key: "modelProvider",
|
|
15
|
+
fileKey: "modelProvider",
|
|
16
|
+
envVar: "MODEL_PROVIDER",
|
|
17
|
+
type: "enum",
|
|
18
|
+
values: ["anthropic", "openai-compatible"],
|
|
19
|
+
description: "Provider backend used to create the model client",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
key: "model",
|
|
23
|
+
fileKey: "model",
|
|
24
|
+
envVar: "MODEL",
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Default model id for new sessions",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
key: "maxSteps",
|
|
30
|
+
fileKey: "maxSteps",
|
|
31
|
+
envVar: "MAX_STEPS",
|
|
32
|
+
type: "number",
|
|
33
|
+
description: "Turn call limit before the agent pauses and waits for another prompt",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
key: "maxTokens",
|
|
37
|
+
fileKey: "maxTokens",
|
|
38
|
+
envVar: "MAX_TOKENS",
|
|
39
|
+
type: "number",
|
|
40
|
+
description: "Maximum completion tokens per model response",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: "maxContextTokens",
|
|
44
|
+
fileKey: "maxContextTokens",
|
|
45
|
+
envVar: "MAX_CONTEXT_TOKENS",
|
|
46
|
+
type: "number",
|
|
47
|
+
description: "Estimated prompt-context budget before compaction",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
key: "commandTimeoutMs",
|
|
51
|
+
fileKey: "commandTimeout",
|
|
52
|
+
envVar: "COMMAND_TIMEOUT_MS",
|
|
53
|
+
type: "number",
|
|
54
|
+
description: "Shell command timeout in milliseconds",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: "maxFileSizeBytes",
|
|
58
|
+
fileKey: "maxFileSizeBytes",
|
|
59
|
+
envVar: "MAX_FILE_SIZE_BYTES",
|
|
60
|
+
type: "number",
|
|
61
|
+
description: "Maximum file size allowed for read/edit tools",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
key: "confirmDestructive",
|
|
65
|
+
fileKey: "confirmDestructive",
|
|
66
|
+
envVar: "CONFIRM_DESTRUCTIVE",
|
|
67
|
+
type: "boolean",
|
|
68
|
+
description: "Whether destructive commands require confirmation",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
key: "keepRecentMessages",
|
|
72
|
+
fileKey: "keepRecentMessages",
|
|
73
|
+
envVar: "KEEP_RECENT_MESSAGES",
|
|
74
|
+
type: "number",
|
|
75
|
+
description: "Recent messages preserved when trimming session history",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
key: "loopDetectionWindow",
|
|
79
|
+
fileKey: "loopDetectionWindow",
|
|
80
|
+
envVar: "LOOP_DETECTION_WINDOW",
|
|
81
|
+
type: "number",
|
|
82
|
+
description: "Window size for repeated-tool-call loop detection",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
key: "maxToolOutputChars",
|
|
86
|
+
fileKey: "maxToolOutputChars",
|
|
87
|
+
envVar: "MAX_TOOL_OUTPUT_CHARS",
|
|
88
|
+
type: "number",
|
|
89
|
+
description: "Maximum tool output retained before truncation",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: "openAiBaseUrl",
|
|
93
|
+
fileKey: "openAiBaseUrl",
|
|
94
|
+
envVar: "OPENAI_BASE_URL",
|
|
95
|
+
type: "string",
|
|
96
|
+
description: "Base URL for OpenAI-compatible providers",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
key: "enableFileReadDedup",
|
|
100
|
+
fileKey: "enableFileReadDedup",
|
|
101
|
+
envVar: "ENABLE_FILE_READ_DEDUP",
|
|
102
|
+
type: "boolean",
|
|
103
|
+
description: "Deduplicate repeated file reads in prompt context",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: "enableAdaptiveKeepRecent",
|
|
107
|
+
fileKey: "enableAdaptiveKeepRecent",
|
|
108
|
+
envVar: "ENABLE_ADAPTIVE_KEEP_RECENT",
|
|
109
|
+
type: "boolean",
|
|
110
|
+
description: "Adjust recent-message retention based on context pressure",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
key: "enableToolOutputTruncation",
|
|
114
|
+
fileKey: "enableToolOutputTruncation",
|
|
115
|
+
envVar: "ENABLE_TOOL_OUTPUT_TRUNCATION",
|
|
116
|
+
type: "boolean",
|
|
117
|
+
description: "Truncate oversized tool output before storing it in session history",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
key: "compactionThreshold",
|
|
121
|
+
fileKey: "compactionThreshold",
|
|
122
|
+
envVar: "COMPACTION_THRESHOLD",
|
|
123
|
+
type: "number",
|
|
124
|
+
description: "Compaction threshold ratio used before a turn starts",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
key: "compactionModel",
|
|
128
|
+
fileKey: "compactionModel",
|
|
129
|
+
envVar: "COMPACTION_MODEL",
|
|
130
|
+
type: "string",
|
|
131
|
+
description: "Optional model id used for LLM-based compaction",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: "reasoningEffort",
|
|
135
|
+
fileKey: "reasoningEffort",
|
|
136
|
+
envVar: "REASONING_EFFORT",
|
|
137
|
+
type: "enum",
|
|
138
|
+
values: REASONING_VALUES,
|
|
139
|
+
description: "Reasoning effort sent to supported model providers",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: "enableDynamicPrompt",
|
|
143
|
+
fileKey: "enableDynamicPrompt",
|
|
144
|
+
envVar: "ENABLE_DYNAMIC_PROMPT",
|
|
145
|
+
type: "boolean",
|
|
146
|
+
description: "Toggle project-aware dynamic prompt generation",
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
const definitionByKey = new Map(EDITABLE_CONFIG_DEFINITIONS.map((definition) => [definition.key, definition]));
|
|
150
|
+
function parseBoolean(value) {
|
|
151
|
+
const normalized = value.trim().toLowerCase();
|
|
152
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
function parseEditableValue(definition, rawValue) {
|
|
161
|
+
if (definition.type === "number") {
|
|
162
|
+
const value = Number(rawValue);
|
|
163
|
+
if (!Number.isFinite(value)) {
|
|
164
|
+
throw new Error(`Expected a number for "${definition.key}".`);
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
if (definition.type === "boolean") {
|
|
169
|
+
const value = parseBoolean(rawValue);
|
|
170
|
+
if (value === undefined) {
|
|
171
|
+
throw new Error(`Expected a boolean for "${definition.key}" (true/false, yes/no, on/off).`);
|
|
172
|
+
}
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
if (definition.type === "enum") {
|
|
176
|
+
const normalized = rawValue.trim().toLowerCase();
|
|
177
|
+
const match = definition.values?.find((value) => value.toLowerCase() === normalized);
|
|
178
|
+
if (!match) {
|
|
179
|
+
throw new Error(`Expected one of: ${definition.values?.join(", ")}`);
|
|
180
|
+
}
|
|
181
|
+
return match;
|
|
182
|
+
}
|
|
183
|
+
const trimmed = rawValue.trim();
|
|
184
|
+
if (trimmed.length === 0) {
|
|
185
|
+
throw new Error(`Expected a non-empty value for "${definition.key}".`);
|
|
186
|
+
}
|
|
187
|
+
return trimmed;
|
|
188
|
+
}
|
|
189
|
+
function normalizePersistedValue(value) {
|
|
190
|
+
if (value === undefined)
|
|
191
|
+
return null;
|
|
192
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
function isEmptyConfigFile(config) {
|
|
198
|
+
return Object.keys(config).length === 0;
|
|
199
|
+
}
|
|
200
|
+
export function listEditableConfigDefinitions() {
|
|
201
|
+
return EDITABLE_CONFIG_DEFINITIONS;
|
|
202
|
+
}
|
|
203
|
+
export function isEditableConfigKey(value) {
|
|
204
|
+
return definitionByKey.has(value);
|
|
205
|
+
}
|
|
206
|
+
export function getEditableConfigDefinition(key) {
|
|
207
|
+
const definition = definitionByKey.get(key);
|
|
208
|
+
if (!definition) {
|
|
209
|
+
throw new Error(`Unknown editable config key "${key}".`);
|
|
210
|
+
}
|
|
211
|
+
return definition;
|
|
212
|
+
}
|
|
213
|
+
export function getEffectiveEditableConfigValue(config, key) {
|
|
214
|
+
return formatPersistedConfigValue(config[key]);
|
|
215
|
+
}
|
|
216
|
+
export function formatPersistedConfigValue(value) {
|
|
217
|
+
if (value === undefined)
|
|
218
|
+
return "(unset)";
|
|
219
|
+
if (value === null)
|
|
220
|
+
return "(unset)";
|
|
221
|
+
if (typeof value === "string")
|
|
222
|
+
return value;
|
|
223
|
+
return String(value);
|
|
224
|
+
}
|
|
225
|
+
export function getGlobalConfigPath(minicodeHome = MINICODE_HOME) {
|
|
226
|
+
return path.join(minicodeHome, "agent.config.json");
|
|
227
|
+
}
|
|
228
|
+
export async function loadPersistedConfig(minicodeHome = MINICODE_HOME) {
|
|
229
|
+
return loadConfigFile(getGlobalConfigPath(minicodeHome));
|
|
230
|
+
}
|
|
231
|
+
export async function buildStructuredConfigPayload(config, minicodeHome = MINICODE_HOME) {
|
|
232
|
+
const configPath = getGlobalConfigPath(minicodeHome);
|
|
233
|
+
const persisted = await loadPersistedConfig(minicodeHome);
|
|
234
|
+
const env = await resolveConfigEnv({ minicodeHome });
|
|
235
|
+
return {
|
|
236
|
+
configPath,
|
|
237
|
+
entries: EDITABLE_CONFIG_DEFINITIONS.map((definition) => {
|
|
238
|
+
const envValue = env.values[definition.envVar];
|
|
239
|
+
const envSource = env.sources[definition.envVar] ?? null;
|
|
240
|
+
const envSourcePath = envSource === "home-dotenv"
|
|
241
|
+
? env.homeEnvPath
|
|
242
|
+
: null;
|
|
243
|
+
return {
|
|
244
|
+
key: definition.key,
|
|
245
|
+
type: definition.type,
|
|
246
|
+
description: definition.description,
|
|
247
|
+
envVar: definition.envVar,
|
|
248
|
+
...(definition.values ? { values: definition.values } : {}),
|
|
249
|
+
effectiveValue: normalizePersistedValue(config[definition.key]),
|
|
250
|
+
persistedValue: normalizePersistedValue(persisted[definition.fileKey]),
|
|
251
|
+
envValue: envValue ?? null,
|
|
252
|
+
envSource,
|
|
253
|
+
envSourcePath,
|
|
254
|
+
overriddenByEnv: envValue !== undefined,
|
|
255
|
+
};
|
|
256
|
+
}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
export async function setPersistedConfigValue(options) {
|
|
260
|
+
const minicodeHome = options.minicodeHome ?? MINICODE_HOME;
|
|
261
|
+
const definition = getEditableConfigDefinition(options.key);
|
|
262
|
+
const configPath = getGlobalConfigPath(minicodeHome);
|
|
263
|
+
const nextFile = await loadConfigFile(configPath);
|
|
264
|
+
const storedValue = parseEditableValue(definition, options.rawValue);
|
|
265
|
+
nextFile[definition.fileKey] = storedValue;
|
|
266
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
267
|
+
await writeFile(configPath, JSON.stringify(nextFile, null, 2) + "\n", "utf8");
|
|
268
|
+
return { path: configPath, storedValue };
|
|
269
|
+
}
|
|
270
|
+
export async function unsetPersistedConfigValue(options) {
|
|
271
|
+
const minicodeHome = options.minicodeHome ?? MINICODE_HOME;
|
|
272
|
+
const definition = getEditableConfigDefinition(options.key);
|
|
273
|
+
const configPath = getGlobalConfigPath(minicodeHome);
|
|
274
|
+
const nextFile = await loadConfigFile(configPath);
|
|
275
|
+
delete nextFile[definition.fileKey];
|
|
276
|
+
if (isEmptyConfigFile(nextFile)) {
|
|
277
|
+
await rm(configPath, { force: true });
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
281
|
+
await writeFile(configPath, JSON.stringify(nextFile, null, 2) + "\n", "utf8");
|
|
282
|
+
}
|
|
283
|
+
return { path: configPath };
|
|
284
|
+
}
|
|
285
|
+
export async function applyPersistedConfigUpdates(options) {
|
|
286
|
+
const minicodeHome = options.minicodeHome ?? MINICODE_HOME;
|
|
287
|
+
const planned = Object.entries(options.updates).map(([rawKey, value]) => {
|
|
288
|
+
if (!isEditableConfigKey(rawKey)) {
|
|
289
|
+
throw new Error(`Unknown editable config key "${rawKey}".`);
|
|
290
|
+
}
|
|
291
|
+
return { key: rawKey, value };
|
|
292
|
+
});
|
|
293
|
+
const saved = [];
|
|
294
|
+
for (const item of planned) {
|
|
295
|
+
if (item.value === null) {
|
|
296
|
+
await unsetPersistedConfigValue({
|
|
297
|
+
key: item.key,
|
|
298
|
+
minicodeHome,
|
|
299
|
+
});
|
|
300
|
+
saved.push({ key: item.key, value: null });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
await setPersistedConfigValue({
|
|
304
|
+
key: item.key,
|
|
305
|
+
rawValue: String(item.value),
|
|
306
|
+
minicodeHome,
|
|
307
|
+
});
|
|
308
|
+
saved.push({ key: item.key, value: item.value });
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
path: getGlobalConfigPath(minicodeHome),
|
|
312
|
+
saved,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
function quantile(values, percentile) {
|
|
2
|
+
if (values.length === 0)
|
|
3
|
+
return 0;
|
|
4
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
5
|
+
const position = Math.max(0, Math.min(sorted.length - 1, Math.ceil((sorted.length - 1) * percentile)));
|
|
6
|
+
return sorted[position] ?? 0;
|
|
7
|
+
}
|
|
8
|
+
function thresholdFor(values, minimum, percentile = 0.9) {
|
|
9
|
+
const activeValues = values.filter((value) => value > 0);
|
|
10
|
+
return Math.max(minimum, Math.ceil(quantile(activeValues, percentile)));
|
|
11
|
+
}
|
|
12
|
+
function uniqueSorted(values) {
|
|
13
|
+
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
14
|
+
}
|
|
15
|
+
function buildAdjacencyFrom(edges) {
|
|
16
|
+
const adjacency = new Map();
|
|
17
|
+
for (const edge of edges) {
|
|
18
|
+
const list = adjacency.get(edge.from);
|
|
19
|
+
if (list) {
|
|
20
|
+
list.push(edge.to);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
adjacency.set(edge.from, [edge.to]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return adjacency;
|
|
27
|
+
}
|
|
28
|
+
function findStronglyConnectedComponents(symbols, edges) {
|
|
29
|
+
const adjacency = buildAdjacencyFrom(edges);
|
|
30
|
+
const indexByNode = new Map();
|
|
31
|
+
const lowLink = new Map();
|
|
32
|
+
const stack = [];
|
|
33
|
+
const onStack = new Set();
|
|
34
|
+
const components = [];
|
|
35
|
+
let currentIndex = 0;
|
|
36
|
+
function strongConnect(node) {
|
|
37
|
+
indexByNode.set(node, currentIndex);
|
|
38
|
+
lowLink.set(node, currentIndex);
|
|
39
|
+
currentIndex += 1;
|
|
40
|
+
stack.push(node);
|
|
41
|
+
onStack.add(node);
|
|
42
|
+
for (const next of adjacency.get(node) ?? []) {
|
|
43
|
+
if (!symbols.has(next))
|
|
44
|
+
continue;
|
|
45
|
+
if (!indexByNode.has(next)) {
|
|
46
|
+
strongConnect(next);
|
|
47
|
+
lowLink.set(node, Math.min(lowLink.get(node) ?? 0, lowLink.get(next) ?? 0));
|
|
48
|
+
}
|
|
49
|
+
else if (onStack.has(next)) {
|
|
50
|
+
lowLink.set(node, Math.min(lowLink.get(node) ?? 0, indexByNode.get(next) ?? 0));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if ((lowLink.get(node) ?? -1) !== (indexByNode.get(node) ?? -2)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const component = [];
|
|
57
|
+
while (stack.length > 0) {
|
|
58
|
+
const popped = stack.pop();
|
|
59
|
+
onStack.delete(popped);
|
|
60
|
+
component.push(popped);
|
|
61
|
+
if (popped === node)
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
components.push(component.sort((a, b) => a.localeCompare(b)));
|
|
65
|
+
}
|
|
66
|
+
for (const node of symbols.keys()) {
|
|
67
|
+
if (!indexByNode.has(node)) {
|
|
68
|
+
strongConnect(node);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return components;
|
|
72
|
+
}
|
|
73
|
+
function severityRank(severity) {
|
|
74
|
+
switch (severity) {
|
|
75
|
+
case "high":
|
|
76
|
+
return 0;
|
|
77
|
+
case "warning":
|
|
78
|
+
return 1;
|
|
79
|
+
case "info":
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function compareFindings(a, b) {
|
|
84
|
+
const severityDiff = severityRank(a.severity) - severityRank(b.severity);
|
|
85
|
+
if (severityDiff !== 0)
|
|
86
|
+
return severityDiff;
|
|
87
|
+
const metricA = Number(a.metrics.score ?? a.metrics.totalDegree ?? a.metrics.cycleSize ?? a.metrics.totalCoupling ?? 0);
|
|
88
|
+
const metricB = Number(b.metrics.score ?? b.metrics.totalDegree ?? b.metrics.cycleSize ?? b.metrics.totalCoupling ?? 0);
|
|
89
|
+
if (metricB !== metricA)
|
|
90
|
+
return metricB - metricA;
|
|
91
|
+
return a.title.localeCompare(b.title);
|
|
92
|
+
}
|
|
93
|
+
function shouldSuppressSymbolFinding(metric) {
|
|
94
|
+
return metric.kind === "interface" || metric.kind === "type";
|
|
95
|
+
}
|
|
96
|
+
function isSmallCompositionRootSymbol(metric) {
|
|
97
|
+
return ((metric.kind === "function" || metric.kind === "method") &&
|
|
98
|
+
metric.spanLines <= 40 &&
|
|
99
|
+
metric.fanIn <= 3 &&
|
|
100
|
+
metric.fanOut >= 8);
|
|
101
|
+
}
|
|
102
|
+
function isContractModule(fileSymbols, fileMetric) {
|
|
103
|
+
return (fileSymbols.length > 0 &&
|
|
104
|
+
fileMetric.efferentCoupling === 0 &&
|
|
105
|
+
fileSymbols.every((symbol) => symbol.kind === "interface" || symbol.kind === "type"));
|
|
106
|
+
}
|
|
107
|
+
function isCompositionRootFile(fileSymbols, fileMetric) {
|
|
108
|
+
const hasConcreteRuntimeSymbol = fileSymbols.some((symbol) => symbol.kind === "function" ||
|
|
109
|
+
symbol.kind === "class" ||
|
|
110
|
+
symbol.kind === "method" ||
|
|
111
|
+
symbol.kind === "variable");
|
|
112
|
+
return (hasConcreteRuntimeSymbol &&
|
|
113
|
+
fileMetric.symbolCount <= 6 &&
|
|
114
|
+
fileMetric.afferentCoupling <= 3 &&
|
|
115
|
+
fileMetric.instability >= 0.8);
|
|
116
|
+
}
|
|
117
|
+
export function analyzeProjectStructure(projectIndex) {
|
|
118
|
+
const symbols = [...projectIndex.symbols.values()].sort((a, b) => a.qualifiedName.localeCompare(b.qualifiedName));
|
|
119
|
+
const symbolMap = new Map(symbols.map((symbol) => [symbol.qualifiedName, symbol]));
|
|
120
|
+
const edges = projectIndex.dependencyEdges.filter((edge) => symbolMap.has(edge.from) && symbolMap.has(edge.to));
|
|
121
|
+
const inbound = new Map();
|
|
122
|
+
const outbound = new Map();
|
|
123
|
+
for (const edge of edges) {
|
|
124
|
+
const outList = outbound.get(edge.from);
|
|
125
|
+
if (outList) {
|
|
126
|
+
outList.push(edge);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
outbound.set(edge.from, [edge]);
|
|
130
|
+
}
|
|
131
|
+
const inList = inbound.get(edge.to);
|
|
132
|
+
if (inList) {
|
|
133
|
+
inList.push(edge);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
inbound.set(edge.to, [edge]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const symbolMetrics = symbols.map((symbol) => {
|
|
140
|
+
const incoming = inbound.get(symbol.qualifiedName) ?? [];
|
|
141
|
+
const outgoing = outbound.get(symbol.qualifiedName) ?? [];
|
|
142
|
+
return {
|
|
143
|
+
qualifiedName: symbol.qualifiedName,
|
|
144
|
+
name: symbol.name,
|
|
145
|
+
kind: symbol.kind,
|
|
146
|
+
filePath: symbol.filePath,
|
|
147
|
+
spanLines: Math.max(1, symbol.endLine - symbol.startLine + 1),
|
|
148
|
+
fanIn: incoming.length,
|
|
149
|
+
fanOut: outgoing.length,
|
|
150
|
+
totalDegree: incoming.length + outgoing.length,
|
|
151
|
+
inboundKinds: uniqueSorted(incoming.map((edge) => edge.kind)),
|
|
152
|
+
outboundKinds: uniqueSorted(outgoing.map((edge) => edge.kind)),
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
const fileAccumulators = new Map();
|
|
156
|
+
for (const symbol of symbols) {
|
|
157
|
+
const existing = fileAccumulators.get(symbol.filePath);
|
|
158
|
+
if (existing) {
|
|
159
|
+
existing.symbolCount += 1;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
fileAccumulators.set(symbol.filePath, {
|
|
163
|
+
filePath: symbol.filePath,
|
|
164
|
+
symbolCount: 1,
|
|
165
|
+
incomingEdgeCount: 0,
|
|
166
|
+
outgoingEdgeCount: 0,
|
|
167
|
+
internalEdgeCount: 0,
|
|
168
|
+
inboundFiles: new Set(),
|
|
169
|
+
outboundFiles: new Set(),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const edge of edges) {
|
|
174
|
+
const fromSymbol = symbolMap.get(edge.from);
|
|
175
|
+
const toSymbol = symbolMap.get(edge.to);
|
|
176
|
+
if (!fromSymbol || !toSymbol)
|
|
177
|
+
continue;
|
|
178
|
+
const fromFile = fileAccumulators.get(fromSymbol.filePath);
|
|
179
|
+
const toFile = fileAccumulators.get(toSymbol.filePath);
|
|
180
|
+
if (!fromFile || !toFile)
|
|
181
|
+
continue;
|
|
182
|
+
fromFile.outgoingEdgeCount += 1;
|
|
183
|
+
toFile.incomingEdgeCount += 1;
|
|
184
|
+
if (fromSymbol.filePath === toSymbol.filePath) {
|
|
185
|
+
fromFile.internalEdgeCount += 1;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
fromFile.outboundFiles.add(toSymbol.filePath);
|
|
189
|
+
toFile.inboundFiles.add(fromSymbol.filePath);
|
|
190
|
+
}
|
|
191
|
+
const fileMetrics = [...fileAccumulators.values()]
|
|
192
|
+
.map((fileMetric) => {
|
|
193
|
+
const afferentCoupling = fileMetric.inboundFiles.size;
|
|
194
|
+
const efferentCoupling = fileMetric.outboundFiles.size;
|
|
195
|
+
const totalCoupling = afferentCoupling + efferentCoupling;
|
|
196
|
+
const denominator = afferentCoupling + efferentCoupling;
|
|
197
|
+
const instability = denominator === 0 ? 0 : Number((efferentCoupling / denominator).toFixed(3));
|
|
198
|
+
return {
|
|
199
|
+
filePath: fileMetric.filePath,
|
|
200
|
+
symbolCount: fileMetric.symbolCount,
|
|
201
|
+
incomingEdgeCount: fileMetric.incomingEdgeCount,
|
|
202
|
+
outgoingEdgeCount: fileMetric.outgoingEdgeCount,
|
|
203
|
+
internalEdgeCount: fileMetric.internalEdgeCount,
|
|
204
|
+
afferentCoupling,
|
|
205
|
+
efferentCoupling,
|
|
206
|
+
totalCoupling,
|
|
207
|
+
instability,
|
|
208
|
+
};
|
|
209
|
+
})
|
|
210
|
+
.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
211
|
+
const thresholds = {
|
|
212
|
+
fanIn: thresholdFor(symbolMetrics.map((metric) => metric.fanIn), 3, 0.98),
|
|
213
|
+
fanOut: thresholdFor(symbolMetrics.map((metric) => metric.fanOut), 3, 0.98),
|
|
214
|
+
hotspot: thresholdFor(symbolMetrics.map((metric) => metric.totalDegree), 4, 0.99),
|
|
215
|
+
fileCoupling: thresholdFor(fileMetrics.map((metric) => metric.totalCoupling), 3, 0.95),
|
|
216
|
+
};
|
|
217
|
+
const fileSymbolsByPath = new Map();
|
|
218
|
+
for (const symbol of symbols) {
|
|
219
|
+
const list = fileSymbolsByPath.get(symbol.filePath);
|
|
220
|
+
if (list) {
|
|
221
|
+
list.push(symbol);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
fileSymbolsByPath.set(symbol.filePath, [symbol]);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const findings = [];
|
|
228
|
+
const components = findStronglyConnectedComponents(symbolMap, edges);
|
|
229
|
+
for (const component of components) {
|
|
230
|
+
const componentSet = new Set(component);
|
|
231
|
+
const cycleEdges = edges.filter((edge) => componentSet.has(edge.from) && componentSet.has(edge.to));
|
|
232
|
+
if (component.length < 2) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const files = uniqueSorted(component
|
|
236
|
+
.map((qualifiedName) => symbolMap.get(qualifiedName)?.filePath)
|
|
237
|
+
.filter((value) => Boolean(value)));
|
|
238
|
+
findings.push({
|
|
239
|
+
id: `cycle:${component.join("->")}`,
|
|
240
|
+
type: "cycle",
|
|
241
|
+
severity: component.length >= 3 ? "high" : "warning",
|
|
242
|
+
title: `Cycle across ${component.length} symbols`,
|
|
243
|
+
summary: `${component.length} symbols participate in a strongly connected component.`,
|
|
244
|
+
symbols: component,
|
|
245
|
+
files,
|
|
246
|
+
metrics: {
|
|
247
|
+
cycleSize: component.length,
|
|
248
|
+
edgeCount: cycleEdges.length,
|
|
249
|
+
fileCount: files.length,
|
|
250
|
+
},
|
|
251
|
+
rationale: [
|
|
252
|
+
"Strongly connected component detected using deterministic cycle analysis.",
|
|
253
|
+
`Cycle includes ${cycleEdges.length} internal dependency edges across ${files.length} file(s).`,
|
|
254
|
+
],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
for (const metric of symbolMetrics) {
|
|
258
|
+
if (shouldSuppressSymbolFinding(metric) || isSmallCompositionRootSymbol(metric)) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const isHotspot = metric.totalDegree >= thresholds.hotspot && metric.totalDegree > 0;
|
|
262
|
+
if (!isHotspot && metric.fanIn >= thresholds.fanIn && metric.fanIn > 0) {
|
|
263
|
+
findings.push({
|
|
264
|
+
id: `fanin:${metric.qualifiedName}`,
|
|
265
|
+
type: "fanInOutlier",
|
|
266
|
+
severity: metric.fanIn >= thresholds.hotspot ? "warning" : "info",
|
|
267
|
+
title: `${metric.qualifiedName} has unusually high fan-in`,
|
|
268
|
+
summary: `${metric.qualifiedName} is referenced by ${metric.fanIn} inbound dependencies.`,
|
|
269
|
+
symbols: [metric.qualifiedName],
|
|
270
|
+
files: [metric.filePath],
|
|
271
|
+
metrics: {
|
|
272
|
+
fanIn: metric.fanIn,
|
|
273
|
+
threshold: thresholds.fanIn,
|
|
274
|
+
},
|
|
275
|
+
rationale: [
|
|
276
|
+
`Inbound dependency count (${metric.fanIn}) meets or exceeds the current fan-in threshold (${thresholds.fanIn}).`,
|
|
277
|
+
"High fan-in can indicate a central dependency or change hotspot.",
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (!isHotspot && metric.fanOut >= thresholds.fanOut && metric.fanOut > 0) {
|
|
282
|
+
findings.push({
|
|
283
|
+
id: `fanout:${metric.qualifiedName}`,
|
|
284
|
+
type: "fanOutOutlier",
|
|
285
|
+
severity: metric.fanOut >= thresholds.hotspot ? "warning" : "info",
|
|
286
|
+
title: `${metric.qualifiedName} has unusually high fan-out`,
|
|
287
|
+
summary: `${metric.qualifiedName} depends on ${metric.fanOut} outbound symbols.`,
|
|
288
|
+
symbols: [metric.qualifiedName],
|
|
289
|
+
files: [metric.filePath],
|
|
290
|
+
metrics: {
|
|
291
|
+
fanOut: metric.fanOut,
|
|
292
|
+
threshold: thresholds.fanOut,
|
|
293
|
+
},
|
|
294
|
+
rationale: [
|
|
295
|
+
`Outbound dependency count (${metric.fanOut}) meets or exceeds the current fan-out threshold (${thresholds.fanOut}).`,
|
|
296
|
+
"High fan-out can indicate orchestration logic or a symbol with broad coupling.",
|
|
297
|
+
],
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
if (isHotspot) {
|
|
301
|
+
findings.push({
|
|
302
|
+
id: `hotspot:${metric.qualifiedName}`,
|
|
303
|
+
type: "hotspot",
|
|
304
|
+
severity: metric.totalDegree >= thresholds.hotspot + 2 ? "warning" : "info",
|
|
305
|
+
title: `${metric.qualifiedName} is a structural hotspot`,
|
|
306
|
+
summary: `${metric.qualifiedName} has total degree ${metric.totalDegree} (${metric.fanIn} in / ${metric.fanOut} out).`,
|
|
307
|
+
symbols: [metric.qualifiedName],
|
|
308
|
+
files: [metric.filePath],
|
|
309
|
+
metrics: {
|
|
310
|
+
totalDegree: metric.totalDegree,
|
|
311
|
+
fanIn: metric.fanIn,
|
|
312
|
+
fanOut: metric.fanOut,
|
|
313
|
+
threshold: thresholds.hotspot,
|
|
314
|
+
score: metric.totalDegree,
|
|
315
|
+
},
|
|
316
|
+
rationale: [
|
|
317
|
+
`Total graph degree (${metric.totalDegree}) meets or exceeds the hotspot threshold (${thresholds.hotspot}).`,
|
|
318
|
+
"High total degree suggests a node that concentrates incoming and outgoing dependencies.",
|
|
319
|
+
],
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
for (const metric of fileMetrics) {
|
|
324
|
+
if (metric.totalCoupling < thresholds.fileCoupling || metric.totalCoupling === 0) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const fileSymbolEntries = fileSymbolsByPath.get(metric.filePath) ?? [];
|
|
328
|
+
if (isContractModule(fileSymbolEntries, metric) ||
|
|
329
|
+
isCompositionRootFile(fileSymbolEntries, metric)) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const fileSymbols = fileSymbolEntries.map((symbol) => symbol.qualifiedName);
|
|
333
|
+
findings.push({
|
|
334
|
+
id: `file-coupling:${metric.filePath}`,
|
|
335
|
+
type: "fileCoupling",
|
|
336
|
+
severity: metric.totalCoupling >= thresholds.fileCoupling + 2 ? "warning" : "info",
|
|
337
|
+
title: `${metric.filePath} has elevated file-level coupling`,
|
|
338
|
+
summary: `${metric.filePath} has afferent coupling ${metric.afferentCoupling}, efferent coupling ${metric.efferentCoupling}, and instability ${metric.instability}.`,
|
|
339
|
+
symbols: fileSymbols,
|
|
340
|
+
files: [metric.filePath],
|
|
341
|
+
metrics: {
|
|
342
|
+
afferentCoupling: metric.afferentCoupling,
|
|
343
|
+
efferentCoupling: metric.efferentCoupling,
|
|
344
|
+
totalCoupling: metric.totalCoupling,
|
|
345
|
+
instability: metric.instability,
|
|
346
|
+
threshold: thresholds.fileCoupling,
|
|
347
|
+
score: metric.totalCoupling,
|
|
348
|
+
},
|
|
349
|
+
rationale: [
|
|
350
|
+
`Distinct file coupling count (${metric.totalCoupling}) meets or exceeds the file-coupling threshold (${thresholds.fileCoupling}).`,
|
|
351
|
+
"Instability is computed as efferent / (afferent + efferent) using cross-file edges only.",
|
|
352
|
+
],
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
findings.sort(compareFindings);
|
|
356
|
+
return {
|
|
357
|
+
version: 1,
|
|
358
|
+
findings,
|
|
359
|
+
symbolMetrics: [...symbolMetrics].sort((a, b) => {
|
|
360
|
+
if (b.totalDegree !== a.totalDegree)
|
|
361
|
+
return b.totalDegree - a.totalDegree;
|
|
362
|
+
return a.qualifiedName.localeCompare(b.qualifiedName);
|
|
363
|
+
}),
|
|
364
|
+
fileMetrics: [...fileMetrics].sort((a, b) => {
|
|
365
|
+
if (b.totalCoupling !== a.totalCoupling)
|
|
366
|
+
return b.totalCoupling - a.totalCoupling;
|
|
367
|
+
return a.filePath.localeCompare(b.filePath);
|
|
368
|
+
}),
|
|
369
|
+
summary: {
|
|
370
|
+
symbolCount: symbolMetrics.length,
|
|
371
|
+
edgeCount: edges.length,
|
|
372
|
+
fileCount: fileMetrics.length,
|
|
373
|
+
findingCount: findings.length,
|
|
374
|
+
cycleCount: findings.filter((finding) => finding.type === "cycle").length,
|
|
375
|
+
hotspotCount: findings.filter((finding) => finding.type === "hotspot").length,
|
|
376
|
+
thresholds,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|