@dev-tren/mapd 0.21.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/MASTER_PROMPT.md +134 -0
- package/README.md +494 -0
- package/SETUP.md +108 -0
- package/UAT.md +77 -0
- package/package.json +56 -0
- package/src/adapters/github-app.js +79 -0
- package/src/agents/anthropicClient.js +18 -0
- package/src/agents/llm.js +196 -0
- package/src/agents/modelResolver.js +87 -0
- package/src/agents/provider.js +222 -0
- package/src/chat/commandRunner.js +86 -0
- package/src/chat/commands.js +275 -0
- package/src/chat/intent.js +87 -0
- package/src/chat/llmIntent.js +118 -0
- package/src/chat/repl.js +471 -0
- package/src/cli.js +1408 -0
- package/src/config/index.js +197 -0
- package/src/config/schema.js +119 -0
- package/src/core/assist.js +64 -0
- package/src/core/audit.js +63 -0
- package/src/core/changes.js +110 -0
- package/src/core/confidence.js +0 -0
- package/src/core/configLint.js +141 -0
- package/src/core/diagnose.js +262 -0
- package/src/core/docs.js +140 -0
- package/src/core/doctor.js +134 -0
- package/src/core/envFiles.js +43 -0
- package/src/core/events.js +53 -0
- package/src/core/evidence.js +212 -0
- package/src/core/findingScoring.js +20 -0
- package/src/core/fix.js +192 -0
- package/src/core/fixApply.js +172 -0
- package/src/core/frameworkEntries.js +247 -0
- package/src/core/gates.js +209 -0
- package/src/core/graph.js +467 -0
- package/src/core/grounding.js +235 -0
- package/src/core/handoff.js +157 -0
- package/src/core/importResolver.js +218 -0
- package/src/core/improve.js +226 -0
- package/src/core/integrate.js +169 -0
- package/src/core/intelligence.js +212 -0
- package/src/core/modernize.js +370 -0
- package/src/core/parseCache.js +64 -0
- package/src/core/parser.js +536 -0
- package/src/core/policy.js +65 -0
- package/src/core/polyglot.js +333 -0
- package/src/core/proc.js +25 -0
- package/src/core/reachability.js +543 -0
- package/src/core/regression.js +193 -0
- package/src/core/resolution.js +92 -0
- package/src/core/retry.js +61 -0
- package/src/core/review.js +219 -0
- package/src/core/score.js +338 -0
- package/src/core/security.js +0 -0
- package/src/core/session.js +143 -0
- package/src/core/solutions.js +254 -0
- package/src/core/staleness.js +45 -0
- package/src/core/testGuidance.js +226 -0
- package/src/core/theme.js +50 -0
- package/src/core/trace.js +151 -0
- package/src/core/verify.js +123 -0
- package/src/core/view.js +221 -0
- package/src/core/viewServer.js +88 -0
- package/src/core/watch.js +76 -0
- package/src/core/workspace.js +115 -0
- package/src/mcp/server.js +48 -0
- package/src/mcp/tools.js +423 -0
- package/src/server.js +84 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graph.js — Builds the project graph from parsed FileNodes and derives
|
|
3
|
+
* workflow subgraphs. Fully deterministic.
|
|
4
|
+
*
|
|
5
|
+
* A "workflow" = the reachable call/import subgraph from a detected entry
|
|
6
|
+
* point (bin script, main module, HTTP route registration, CLI command,
|
|
7
|
+
* exported public API of the root package).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { detectFrameworkEntries } from "./frameworkEntries.js";
|
|
13
|
+
import { classifyUncoveredFiles, detectGeneratedFiles, applyAnnotations } from "./reachability.js";
|
|
14
|
+
import { createImportResolver } from "./importResolver.js";
|
|
15
|
+
import { createPolyglotResolver } from "./polyglot.js";
|
|
16
|
+
|
|
17
|
+
// Import resolution lives in importResolver.js — it resolves specifiers the
|
|
18
|
+
// way the project's own toolchain does (tsconfig paths, package.json
|
|
19
|
+
// imports/exports, TS-ESM extension rewriting), not just relative paths.
|
|
20
|
+
|
|
21
|
+
/** A test file is unreachable-from-entry-points by design, not dead code — shared with modernize.js's scoring. */
|
|
22
|
+
export const isTestFile = (f) => /(\.test\.|\.spec\.|__tests__\/|(^|\/)tests?\/)/.test(f);
|
|
23
|
+
|
|
24
|
+
const ROUTE_METHODS = new Set(["get", "post", "put", "delete", "patch", "use", "all"]);
|
|
25
|
+
const ROUTE_RECEIVERS = new Set(["app", "router", "server", "api", "fastify", "express"]);
|
|
26
|
+
export const GLOBALS = new Set([
|
|
27
|
+
"console", "JSON", "Math", "Object", "Array", "Promise", "Number", "String", "Date",
|
|
28
|
+
"process", "Buffer", "Set", "Map", "RegExp", "Error", "Symbol", "Boolean", "URL",
|
|
29
|
+
"fetch", "setTimeout", "clearTimeout", "setInterval", "clearInterval", "parseInt",
|
|
30
|
+
"parseFloat", "isNaN", "structuredClone", "queueMicrotask", "require", "import",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/** tsconfig's outDir/rootDir, tolerating comments and trailing commas. */
|
|
34
|
+
function readBuildDirs(rootDir) {
|
|
35
|
+
for (const name of ["tsconfig.json", "jsconfig.json"]) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(path.join(rootDir, name), "utf8");
|
|
38
|
+
const stripped = raw
|
|
39
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
40
|
+
.replace(/(^|[^:])\/\/.*$/gm, "$1")
|
|
41
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
42
|
+
const co = JSON.parse(stripped).compilerOptions ?? {};
|
|
43
|
+
const norm = (v) => (typeof v === "string" ? v.replace(/^\.\//, "").replace(/\/$/, "") : null);
|
|
44
|
+
if (co.outDir) return { outDir: norm(co.outDir), rootDir: norm(co.rootDir) };
|
|
45
|
+
} catch {
|
|
46
|
+
// absent or unparseable — the conventional fallbacks still apply
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { outDir: null, rootDir: null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildGraph(rootDir, parseResult, pkg, { annotations = {} } = {}) {
|
|
53
|
+
const { files } = parseResult;
|
|
54
|
+
const fileSet = new Set(files.map((f) => f.file.split(path.sep).join("/")));
|
|
55
|
+
const byFile = new Map(files.map((f) => [f.file.split(path.sep).join("/"), f]));
|
|
56
|
+
const resolver = createImportResolver(rootDir, fileSet, pkg);
|
|
57
|
+
const polyglotResolver = createPolyglotResolver(fileSet);
|
|
58
|
+
|
|
59
|
+
// ---- edges ----------------------------------------------------------
|
|
60
|
+
const importEdges = []; // { from, to } | { from, external } | { from, unresolved }
|
|
61
|
+
let resolvedImports = 0, totalInternalImports = 0;
|
|
62
|
+
for (const f of files) {
|
|
63
|
+
const from = f.file.split(path.sep).join("/");
|
|
64
|
+
for (const imp of f.imports) {
|
|
65
|
+
// heuristic-parsed files (polyglot.js) resolve with their own language's
|
|
66
|
+
// rules; JS/TS files keep the toolchain-faithful resolver. Both are
|
|
67
|
+
// existence-checked — an edge only exists if the target file does.
|
|
68
|
+
const r = f.parserKind === "heuristic" ? polyglotResolver.resolve(f, imp.source) : resolver.resolve(from, imp.source);
|
|
69
|
+
if (r.internal) { importEdges.push({ from, to: r.internal }); resolvedImports++; totalInternalImports++; }
|
|
70
|
+
else if (r.internalMany) { for (const to of r.internalMany) importEdges.push({ from, to }); resolvedImports++; totalInternalImports++; }
|
|
71
|
+
else if (r.unresolved) { importEdges.push({ from, unresolved: r.unresolved }); totalInternalImports++; }
|
|
72
|
+
else importEdges.push({ from, external: r.external });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// function-level call resolution (same-file + imported-name heuristic, both deterministic)
|
|
77
|
+
const fnIndex = new Map(); // "file#fn" -> fn record
|
|
78
|
+
const fnByName = new Map(); // exported name -> Set of "file#fn"
|
|
79
|
+
for (const f of files) {
|
|
80
|
+
const file = f.file.split(path.sep).join("/");
|
|
81
|
+
for (const fn of f.functions) {
|
|
82
|
+
const id = `${file}#${fn.name}`;
|
|
83
|
+
fnIndex.set(id, { ...fn, file, id });
|
|
84
|
+
if (fn.exported) {
|
|
85
|
+
if (!fnByName.has(fn.name)) fnByName.set(fn.name, new Set());
|
|
86
|
+
fnByName.get(fn.name).add(id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const callEdges = [];
|
|
92
|
+
let resolvedCalls = 0, totalCalls = 0, dynamicCalls = 0;
|
|
93
|
+
for (const [id, fn] of fnIndex) {
|
|
94
|
+
const fileNode = byFile.get(fn.file);
|
|
95
|
+
const localNames = new Set(fileNode?.functions.map((x) => x.name));
|
|
96
|
+
// names imported into this file, split by whether the source resolves
|
|
97
|
+
// internally — and for internal ones, WHICH file each name came from, so
|
|
98
|
+
// a call can resolve to that exact file instead of relying on the name
|
|
99
|
+
// being globally unique across the whole project.
|
|
100
|
+
const internalImported = new Set();
|
|
101
|
+
const externalImported = new Set();
|
|
102
|
+
const importedNameTarget = new Map(); // name -> the internal file it was imported from
|
|
103
|
+
// heuristic-parsed files extract no calls, and their import specifiers are
|
|
104
|
+
// not JS-resolvable — skip the per-function import pass entirely for them
|
|
105
|
+
const fileImports = fileNode?.parserKind === "heuristic" ? [] : fileNode?.imports ?? [];
|
|
106
|
+
for (const imp of fileImports) {
|
|
107
|
+
const r = resolver.resolve(fn.file, imp.source);
|
|
108
|
+
for (const n of imp.names) {
|
|
109
|
+
if (r.internal) { internalImported.add(n); importedNameTarget.set(n, r.internal); }
|
|
110
|
+
else externalImported.add(n);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const call of fn.calls) {
|
|
114
|
+
totalCalls++;
|
|
115
|
+
const bare = call.split(".")[0];
|
|
116
|
+
const thisMethodTarget = bare === "this" && fn.receiver ? `${fn.receiver}.prototype.${call.slice(5)}` : null;
|
|
117
|
+
if (localNames.has(call)) {
|
|
118
|
+
callEdges.push({ from: id, to: `${fn.file}#${call}`, resolution: "local" });
|
|
119
|
+
resolvedCalls++;
|
|
120
|
+
} else if (thisMethodTarget && localNames.has(thisMethodTarget)) {
|
|
121
|
+
// this.method() inside another prototype method of the same class
|
|
122
|
+
callEdges.push({ from: id, to: `${fn.file}#${thisMethodTarget}`, resolution: "local-receiver" });
|
|
123
|
+
resolvedCalls++;
|
|
124
|
+
} else if (internalImported.has(bare) && !call.includes(".")) {
|
|
125
|
+
// Import-precise resolution: we know exactly which internal file this
|
|
126
|
+
// name was imported from — resolve the call to that file directly if
|
|
127
|
+
// it defines the function, instead of requiring the name to be
|
|
128
|
+
// globally unique (which fails on common names like run/init that
|
|
129
|
+
// several modules legitimately export).
|
|
130
|
+
const target = importedNameTarget.get(bare);
|
|
131
|
+
const targetDefines = byFile.get(target)?.functions.some((x) => x.name === bare);
|
|
132
|
+
if (targetDefines) {
|
|
133
|
+
callEdges.push({ from: id, to: `${target}#${bare}`, resolution: "cross-file" });
|
|
134
|
+
resolvedCalls++;
|
|
135
|
+
} else if (fnByName.has(bare) && fnByName.get(bare).size === 1) {
|
|
136
|
+
// re-exported through a barrel file: the imported file doesn't
|
|
137
|
+
// define it itself — fall back to the unique-global match
|
|
138
|
+
callEdges.push({ from: id, to: [...fnByName.get(bare)][0], resolution: "cross-file" });
|
|
139
|
+
resolvedCalls++;
|
|
140
|
+
} else {
|
|
141
|
+
callEdges.push({ from: id, to: null, unresolvedName: call, resolution: "unresolved" });
|
|
142
|
+
}
|
|
143
|
+
} else if (internalImported.has(bare) && fnByName.has(bare) && fnByName.get(bare).size === 1) {
|
|
144
|
+
// dotted call on an internally-imported binding that happens to be a
|
|
145
|
+
// uniquely-named function — preserved original behavior
|
|
146
|
+
callEdges.push({ from: id, to: [...fnByName.get(bare)][0], resolution: "cross-file" });
|
|
147
|
+
resolvedCalls++;
|
|
148
|
+
} else if (externalImported.has(bare)) {
|
|
149
|
+
callEdges.push({ from: id, to: null, external: bare, resolution: "external" });
|
|
150
|
+
resolvedCalls++; // we know where it goes: an external package
|
|
151
|
+
} else if (GLOBALS.has(bare)) {
|
|
152
|
+
callEdges.push({ from: id, to: null, global: bare, resolution: "global" });
|
|
153
|
+
resolvedCalls++;
|
|
154
|
+
} else if (localNames.has(bare)) {
|
|
155
|
+
// method call on a locally-defined value (e.g. program.command)
|
|
156
|
+
callEdges.push({ from: id, to: `${fn.file}#${bare}`, resolution: "local-receiver" });
|
|
157
|
+
resolvedCalls++;
|
|
158
|
+
} else if (call.includes(".")) {
|
|
159
|
+
// method on a runtime value — statically unresolvable by design, not a
|
|
160
|
+
// resolver failure. Reported separately, excluded from the rate.
|
|
161
|
+
callEdges.push({ from: id, to: null, dynamicReceiver: bare, resolution: "dynamic" });
|
|
162
|
+
dynamicCalls++;
|
|
163
|
+
totalCalls--; // not part of the statically-resolvable population
|
|
164
|
+
} else {
|
|
165
|
+
callEdges.push({ from: id, to: null, unresolvedName: call, resolution: "unresolved" });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- entry points ----------------------------------------------------
|
|
171
|
+
const entryPoints = [];
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* package.json points at BUILD OUTPUT ("bin": "dist/cli.js"), but the graph
|
|
175
|
+
* contains SOURCE. Taking the declared path literally meant the real entry
|
|
176
|
+
* point silently vanished: a TypeScript CLI whose bin is dist/cli.js scored
|
|
177
|
+
* zero real entries, every workflow collapsed, and almost every file looked
|
|
178
|
+
* unreachable. Map the declared path back to the source that produces it.
|
|
179
|
+
*/
|
|
180
|
+
const SRC_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
181
|
+
const BUILD_DIRS = ["dist", "build", "lib", "out", "output", "es", "esm", "cjs", ".output"];
|
|
182
|
+
|
|
183
|
+
const toSource = (declared) => {
|
|
184
|
+
if (fileSet.has(declared)) return declared; // already source
|
|
185
|
+
const noExt = declared.replace(/\.[cm]?[jt]sx?$/, "");
|
|
186
|
+
const candidates = [];
|
|
187
|
+
|
|
188
|
+
// tsconfig is authoritative when it says where output goes.
|
|
189
|
+
const { outDir, rootDir: srcRoot } = readBuildDirs(rootDir);
|
|
190
|
+
if (outDir && noExt.startsWith(outDir + "/")) {
|
|
191
|
+
const rel = noExt.slice(outDir.length + 1);
|
|
192
|
+
for (const base of [srcRoot, "src", "source", ""].filter((v) => v != null)) {
|
|
193
|
+
candidates.push(base ? `${base}/${rel}` : rel);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Otherwise strip a conventional build directory and try the usual sources.
|
|
197
|
+
const seg = noExt.split("/");
|
|
198
|
+
if (seg.length > 1 && BUILD_DIRS.includes(seg[0])) {
|
|
199
|
+
const rel = seg.slice(1).join("/");
|
|
200
|
+
for (const base of ["src", "source", "lib", ""]) candidates.push(base ? `${base}/${rel}` : rel);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const c of candidates) {
|
|
204
|
+
for (const ext of SRC_EXTS) {
|
|
205
|
+
if (fileSet.has(c + ext)) return c + ext;
|
|
206
|
+
}
|
|
207
|
+
if (fileSet.has(c)) return c;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const addEntry = (file, kind, detail) => {
|
|
213
|
+
const resolved = toSource(file);
|
|
214
|
+
if (!resolved) return;
|
|
215
|
+
// Declared twice (bin AND main pointing at the same build file) is one entry.
|
|
216
|
+
if (entryPoints.some((e) => e.file === resolved && e.kind === kind)) return;
|
|
217
|
+
entryPoints.push({
|
|
218
|
+
file: resolved,
|
|
219
|
+
kind,
|
|
220
|
+
detail,
|
|
221
|
+
// Keep the declaration visible so the mapping is auditable, not magic.
|
|
222
|
+
...(resolved === file ? {} : { declaredAs: file }),
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
if (pkg?.bin) {
|
|
226
|
+
const bins = typeof pkg.bin === "string" ? { [pkg.name]: pkg.bin } : pkg.bin;
|
|
227
|
+
for (const [name, p] of Object.entries(bins)) addEntry(path.posix.normalize(p), "bin", name);
|
|
228
|
+
}
|
|
229
|
+
if (pkg?.main) addEntry(path.posix.normalize(pkg.main), "main", "package.json main");
|
|
230
|
+
// npm scripts that launch a node file are entry points (e.g. "start": "node src/server.js")
|
|
231
|
+
for (const [scriptName, cmd] of Object.entries(pkg?.scripts ?? {})) {
|
|
232
|
+
const m = typeof cmd === "string" && cmd.match(/(?:^|\s)node\s+(?:--[^\s]+\s+)*([^\s]+\.(?:js|mjs|cjs))/);
|
|
233
|
+
if (m) addEntry(path.posix.normalize(m[1]), "npm-script", scriptName);
|
|
234
|
+
}
|
|
235
|
+
// HTTP route registrations (express/fastify-style: app.get('/x', handler)),
|
|
236
|
+
// excluding test files — a test calling app.get() exercises a route, it doesn't define one
|
|
237
|
+
for (const f of files) {
|
|
238
|
+
const file = f.file.split(path.sep).join("/");
|
|
239
|
+
if (isTestFile(file)) continue;
|
|
240
|
+
for (const fn of f.functions) {
|
|
241
|
+
for (const call of fn.calls) {
|
|
242
|
+
const [recv, method] = call.split(".");
|
|
243
|
+
if (recv && method && ROUTE_RECEIVERS.has(recv) && ROUTE_METHODS.has(method)) {
|
|
244
|
+
addEntry(file, "http-route-site", call);
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// NestJS-style @Get()/@Post()/etc. method decorators — a distinct routing
|
|
251
|
+
// convention from the call-site heuristic above (verified in parser.js).
|
|
252
|
+
for (const f of files) {
|
|
253
|
+
const file = f.file.split(path.sep).join("/");
|
|
254
|
+
for (const route of f.decoratorRoutes ?? []) {
|
|
255
|
+
addEntry(file, "nestjs-route", `@${route.decorator} ${route.class ?? "?"}.${route.method ?? "?"}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Next.js file-based routing, Vite build entries, Electron preload scripts —
|
|
259
|
+
// each verified against real AST/path structure, never guessed.
|
|
260
|
+
for (const fe of detectFrameworkEntries(rootDir, files, fileSet)) {
|
|
261
|
+
addEntry(fe.file, fe.kind, fe.detail);
|
|
262
|
+
}
|
|
263
|
+
// language-level entry markers from the heuristic adapters (python __main__
|
|
264
|
+
// guard, go `package main` + func main, java `public static void main`,
|
|
265
|
+
// shebangs, ...) — each verified against real file content in polyglot.js
|
|
266
|
+
for (const f of files) {
|
|
267
|
+
const file = f.file.split(path.sep).join("/");
|
|
268
|
+
for (const hint of f.entryHints ?? []) addEntry(file, hint.kind, hint.detail);
|
|
269
|
+
}
|
|
270
|
+
// user-asserted entrypoints from .mapdrc project.annotations — the "tell
|
|
271
|
+
// Map'd" lever for entry conventions no detector recognizes (any language).
|
|
272
|
+
// Labeled as a user assertion, never as detected.
|
|
273
|
+
for (const a of applyAnnotations(fileSet, annotations)) {
|
|
274
|
+
if (a.classification === "entrypoint") addEntry(a.file, "user-annotation", `asserted entrypoint (.mapdrc "${a.pattern}")`);
|
|
275
|
+
}
|
|
276
|
+
// fallback: files nothing imports (roots of the import DAG)
|
|
277
|
+
const importedFiles = new Set(importEdges.filter((e) => e.to).map((e) => e.to));
|
|
278
|
+
if (entryPoints.length === 0) {
|
|
279
|
+
for (const f of fileSet) if (!importedFiles.has(f)) addEntry(f, "import-root", "not imported by any file");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---- workflows: BFS over import edges from each entry point ----------
|
|
283
|
+
const adjacency = new Map();
|
|
284
|
+
for (const e of importEdges) {
|
|
285
|
+
if (!e.to) continue;
|
|
286
|
+
if (!adjacency.has(e.from)) adjacency.set(e.from, new Set());
|
|
287
|
+
adjacency.get(e.from).add(e.to);
|
|
288
|
+
}
|
|
289
|
+
const workflows = [];
|
|
290
|
+
const seenRoots = new Set();
|
|
291
|
+
for (const ep of entryPoints) {
|
|
292
|
+
if (seenRoots.has(ep.file)) continue;
|
|
293
|
+
seenRoots.add(ep.file);
|
|
294
|
+
const reached = new Set([ep.file]);
|
|
295
|
+
const queue = [ep.file];
|
|
296
|
+
while (queue.length) {
|
|
297
|
+
const cur = queue.shift();
|
|
298
|
+
for (const next of adjacency.get(cur) ?? []) {
|
|
299
|
+
if (!reached.has(next)) { reached.add(next); queue.push(next); }
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const wfFns = [...reached].flatMap((file) =>
|
|
303
|
+
(byFile.get(file)?.functions ?? []).map((fn) => `${file}#${fn.name}`));
|
|
304
|
+
workflows.push({
|
|
305
|
+
id: `wf:${ep.kind}:${ep.file}`,
|
|
306
|
+
entry: ep,
|
|
307
|
+
files: [...reached].sort(),
|
|
308
|
+
functionCount: wfFns.length,
|
|
309
|
+
exportedSurface: [...reached].flatMap((f) => byFile.get(f)?.exports ?? []).sort(),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// reachability: files reached by no workflow, excluding test files (unreachable
|
|
314
|
+
// from entry points is their normal state, not dead code) — then split further:
|
|
315
|
+
// static tracing genuinely cannot see runtime directory-scan plugin loaders or
|
|
316
|
+
// test-runner glob discovery, so those are their own classification, not
|
|
317
|
+
// silently lumped in with actual orphans. See reachability.js.
|
|
318
|
+
const covered = new Set(workflows.flatMap((w) => w.files));
|
|
319
|
+
const uncovered = [...fileSet].filter((f) => !covered.has(f) && !isTestFile(f));
|
|
320
|
+
// Generated-file status is computed project-wide — a bundler output is
|
|
321
|
+
// generated whether or not something else in the project reaches it, so
|
|
322
|
+
// modernize.js can exclude it from scanning regardless of reachability
|
|
323
|
+
// (this is what `graph.generatedFiles` is for, distinct from
|
|
324
|
+
// `reachability.generatedArtifacts` below, which is scoped to just the
|
|
325
|
+
// uncovered subset for the orphan-cluster exclusion count).
|
|
326
|
+
const generatedFiles = detectGeneratedFiles(rootDir, fileSet, pkg, annotations);
|
|
327
|
+
const { generatedArtifacts, dynamicallyLoaded, intentionalDormant, heuristicUnverified, trulyOrphaned } =
|
|
328
|
+
classifyUncoveredFiles(rootDir, files, fileSet, uncovered, generatedFiles, annotations);
|
|
329
|
+
const orphans = trulyOrphaned;
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
generatedAt: new Date().toISOString(),
|
|
333
|
+
root: rootDir,
|
|
334
|
+
stats: {
|
|
335
|
+
fileCount: files.length,
|
|
336
|
+
parsedCleanly: files.filter((f) => f.parsed && f.parseErrors === 0).length,
|
|
337
|
+
parsedWithRecovery: files.filter((f) => f.parsed && f.parseErrors > 0).length,
|
|
338
|
+
unparsed: parseResult.skipped.length,
|
|
339
|
+
unsupportedLanguageFiles: parseResult.unsupported.length,
|
|
340
|
+
heuristicFileCount: files.filter((f) => f.parserKind === "heuristic").length,
|
|
341
|
+
totalLoc: files.reduce((a, f) => a + f.loc, 0),
|
|
342
|
+
importResolutionRate: totalInternalImports ? resolvedImports / totalInternalImports : 1,
|
|
343
|
+
callResolutionRate: totalCalls ? resolvedCalls / totalCalls : 1,
|
|
344
|
+
totalCalls,
|
|
345
|
+
resolvedCalls,
|
|
346
|
+
dynamicCalls,
|
|
347
|
+
},
|
|
348
|
+
files: files.map((f) => ({ ...f, file: f.file.split(path.sep).join("/") })),
|
|
349
|
+
importEdges,
|
|
350
|
+
callEdges,
|
|
351
|
+
entryPoints,
|
|
352
|
+
workflows,
|
|
353
|
+
orphans,
|
|
354
|
+
generatedFiles,
|
|
355
|
+
reachability: { generatedArtifacts, dynamicallyLoaded, intentionalDormant, heuristicUnverified, trulyOrphaned },
|
|
356
|
+
unsupported: parseResult.unsupported,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function loadPkg(rootDir) {
|
|
361
|
+
try {
|
|
362
|
+
return JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Detects circular import dependencies as strongly-connected components (SCCs)
|
|
370
|
+
* of size > 1 over the resolved-internal import graph (Tarjan's algorithm,
|
|
371
|
+
* implemented iteratively to avoid stack-overflow risk on large real-world
|
|
372
|
+
* codebases with deep import chains). A file that imports itself directly
|
|
373
|
+
* (a true SCC of size 1 with a self-edge) also counts as a cycle.
|
|
374
|
+
*
|
|
375
|
+
* Purely graph-derived and deterministic — the same cycle is reported every
|
|
376
|
+
* time for the same import graph, never a guess.
|
|
377
|
+
*/
|
|
378
|
+
export function detectCircularDependencies(graph) {
|
|
379
|
+
const adjacency = new Map();
|
|
380
|
+
for (const f of graph.files) adjacency.set(f.file, []);
|
|
381
|
+
for (const e of graph.importEdges) {
|
|
382
|
+
if (!e.to) continue;
|
|
383
|
+
if (!adjacency.has(e.from)) adjacency.set(e.from, []);
|
|
384
|
+
adjacency.get(e.from).push(e.to);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let index = 0;
|
|
388
|
+
const indices = new Map();
|
|
389
|
+
const lowlink = new Map();
|
|
390
|
+
const onStack = new Set();
|
|
391
|
+
const stack = [];
|
|
392
|
+
const sccs = [];
|
|
393
|
+
|
|
394
|
+
for (const start of adjacency.keys()) {
|
|
395
|
+
if (indices.has(start)) continue;
|
|
396
|
+
|
|
397
|
+
const callStack = [{ node: start, neighbors: adjacency.get(start) ?? [], i: 0 }];
|
|
398
|
+
indices.set(start, index);
|
|
399
|
+
lowlink.set(start, index);
|
|
400
|
+
index++;
|
|
401
|
+
stack.push(start);
|
|
402
|
+
onStack.add(start);
|
|
403
|
+
|
|
404
|
+
while (callStack.length) {
|
|
405
|
+
const frame = callStack[callStack.length - 1];
|
|
406
|
+
if (frame.i < frame.neighbors.length) {
|
|
407
|
+
const w = frame.neighbors[frame.i++];
|
|
408
|
+
if (!indices.has(w)) {
|
|
409
|
+
indices.set(w, index);
|
|
410
|
+
lowlink.set(w, index);
|
|
411
|
+
index++;
|
|
412
|
+
stack.push(w);
|
|
413
|
+
onStack.add(w);
|
|
414
|
+
callStack.push({ node: w, neighbors: adjacency.get(w) ?? [], i: 0 });
|
|
415
|
+
} else if (onStack.has(w)) {
|
|
416
|
+
lowlink.set(frame.node, Math.min(lowlink.get(frame.node), indices.get(w)));
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
callStack.pop();
|
|
420
|
+
if (callStack.length) {
|
|
421
|
+
const parent = callStack[callStack.length - 1];
|
|
422
|
+
lowlink.set(parent.node, Math.min(lowlink.get(parent.node), lowlink.get(frame.node)));
|
|
423
|
+
}
|
|
424
|
+
if (lowlink.get(frame.node) === indices.get(frame.node)) {
|
|
425
|
+
const component = [];
|
|
426
|
+
let w;
|
|
427
|
+
do {
|
|
428
|
+
w = stack.pop();
|
|
429
|
+
onStack.delete(w);
|
|
430
|
+
component.push(w);
|
|
431
|
+
} while (w !== frame.node);
|
|
432
|
+
if (component.length > 1 || (adjacency.get(frame.node) ?? []).includes(frame.node)) {
|
|
433
|
+
sccs.push(component.sort());
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return sccs.sort((a, b) => a[0].localeCompare(b[0]));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const PACKAGE_MANAGER_LOCKFILES = [
|
|
444
|
+
{ file: "pnpm-lock.yaml", manager: "pnpm" },
|
|
445
|
+
{ file: "yarn.lock", manager: "yarn" },
|
|
446
|
+
{ file: "package-lock.json", manager: "npm" },
|
|
447
|
+
{ file: "bun.lockb", manager: "bun" },
|
|
448
|
+
];
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Detects which package manager a project actually uses from its lockfile.
|
|
452
|
+
* Never assumes npm — falls back to `{manager:"npm", lockfile:null}` only as
|
|
453
|
+
* an explicit last resort when no lockfile exists at all, since some command
|
|
454
|
+
* has to be attempted; callers should treat `lockfile:null` as "unverified."
|
|
455
|
+
*/
|
|
456
|
+
export function detectPackageManager(rootDir) {
|
|
457
|
+
for (const { file, manager } of PACKAGE_MANAGER_LOCKFILES) {
|
|
458
|
+
if (fs.existsSync(path.join(rootDir, file))) return { manager, lockfile: file };
|
|
459
|
+
}
|
|
460
|
+
return { manager: "npm", lockfile: null };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** `run` args differ slightly across package managers (yarn/bun omit the literal "run" for scripts, but accept it too — kept uniform here since all four accept `<pm> run <script>`). */
|
|
464
|
+
export function packageManagerRunArgs(manager, scriptName) {
|
|
465
|
+
if (scriptName === "test") return manager === "yarn" || manager === "bun" ? ["test"] : ["test"];
|
|
466
|
+
return ["run", scriptName];
|
|
467
|
+
}
|