@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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/MASTER_PROMPT.md +134 -0
  3. package/README.md +494 -0
  4. package/SETUP.md +108 -0
  5. package/UAT.md +77 -0
  6. package/package.json +56 -0
  7. package/src/adapters/github-app.js +79 -0
  8. package/src/agents/anthropicClient.js +18 -0
  9. package/src/agents/llm.js +196 -0
  10. package/src/agents/modelResolver.js +87 -0
  11. package/src/agents/provider.js +222 -0
  12. package/src/chat/commandRunner.js +86 -0
  13. package/src/chat/commands.js +275 -0
  14. package/src/chat/intent.js +87 -0
  15. package/src/chat/llmIntent.js +118 -0
  16. package/src/chat/repl.js +471 -0
  17. package/src/cli.js +1408 -0
  18. package/src/config/index.js +197 -0
  19. package/src/config/schema.js +119 -0
  20. package/src/core/assist.js +64 -0
  21. package/src/core/audit.js +63 -0
  22. package/src/core/changes.js +110 -0
  23. package/src/core/confidence.js +0 -0
  24. package/src/core/configLint.js +141 -0
  25. package/src/core/diagnose.js +262 -0
  26. package/src/core/docs.js +140 -0
  27. package/src/core/doctor.js +134 -0
  28. package/src/core/envFiles.js +43 -0
  29. package/src/core/events.js +53 -0
  30. package/src/core/evidence.js +212 -0
  31. package/src/core/findingScoring.js +20 -0
  32. package/src/core/fix.js +192 -0
  33. package/src/core/fixApply.js +172 -0
  34. package/src/core/frameworkEntries.js +247 -0
  35. package/src/core/gates.js +209 -0
  36. package/src/core/graph.js +467 -0
  37. package/src/core/grounding.js +235 -0
  38. package/src/core/handoff.js +157 -0
  39. package/src/core/importResolver.js +218 -0
  40. package/src/core/improve.js +226 -0
  41. package/src/core/integrate.js +169 -0
  42. package/src/core/intelligence.js +212 -0
  43. package/src/core/modernize.js +370 -0
  44. package/src/core/parseCache.js +64 -0
  45. package/src/core/parser.js +536 -0
  46. package/src/core/policy.js +65 -0
  47. package/src/core/polyglot.js +333 -0
  48. package/src/core/proc.js +25 -0
  49. package/src/core/reachability.js +543 -0
  50. package/src/core/regression.js +193 -0
  51. package/src/core/resolution.js +92 -0
  52. package/src/core/retry.js +61 -0
  53. package/src/core/review.js +219 -0
  54. package/src/core/score.js +338 -0
  55. package/src/core/security.js +0 -0
  56. package/src/core/session.js +143 -0
  57. package/src/core/solutions.js +254 -0
  58. package/src/core/staleness.js +45 -0
  59. package/src/core/testGuidance.js +226 -0
  60. package/src/core/theme.js +50 -0
  61. package/src/core/trace.js +151 -0
  62. package/src/core/verify.js +123 -0
  63. package/src/core/view.js +221 -0
  64. package/src/core/viewServer.js +88 -0
  65. package/src/core/watch.js +76 -0
  66. package/src/core/workspace.js +115 -0
  67. package/src/mcp/server.js +48 -0
  68. package/src/mcp/tools.js +423 -0
  69. 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
+ }