@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,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reachability.js — replaces the old binary "covered vs orphan" model with a
|
|
3
|
+
* real taxonomy. Static import/call tracing genuinely cannot see everything:
|
|
4
|
+
* runtime directory-scan plugin loaders, and test runners that discover
|
|
5
|
+
* specs by globbing a config-declared directory rather than importing them,
|
|
6
|
+
* are both legitimate, common patterns that static analysis cannot resolve.
|
|
7
|
+
*
|
|
8
|
+
* The design rule (same as frameworkEntries.js): every detector here either
|
|
9
|
+
* verifies its finding against real AST structure and a real file/directory
|
|
10
|
+
* that exists in the project, or it detects nothing. We never claim a file
|
|
11
|
+
* IS used just because it sits under a dynamically-loaded directory — only
|
|
12
|
+
* that its reachability is legitimately unverifiable by import tracing, so
|
|
13
|
+
* it must not be asserted dead either. That's a distinct bucket from "truly
|
|
14
|
+
* orphaned," not a guess at aliveness.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { parse } from "@babel/parser";
|
|
20
|
+
import _traverse from "@babel/traverse";
|
|
21
|
+
|
|
22
|
+
const traverse = _traverse.default ?? _traverse;
|
|
23
|
+
const BABEL_OPTS = { sourceType: "unambiguous", errorRecovery: true, plugins: ["typescript", "jsx", "decorators-legacy"] };
|
|
24
|
+
|
|
25
|
+
const GENERATED_HEADER_RE = /@generated\b|auto-?generated|generated by|do not edit(?:\s+by hand)?/i;
|
|
26
|
+
const GENERATED_NAME_SEGMENTS = new Set(["bundle", "bundled", "min", "generated"]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Filename convention, checked as a whole path segment (split on -._), not a
|
|
30
|
+
* bare substring — so "plan-mode-bundle.cjs" (real-world hyphenated bundler
|
|
31
|
+
* output) matches just as well as "app.bundle.js" (dot-separated), while a
|
|
32
|
+
* genuinely hand-written file like "bundler.js" (a file *about* bundling,
|
|
33
|
+
* not *produced by* one) does not.
|
|
34
|
+
*/
|
|
35
|
+
function isGeneratedFilename(relFile) {
|
|
36
|
+
const base = path.posix.basename(relFile).replace(/\.[cm]?[jt]sx?$/i, "");
|
|
37
|
+
return base.split(/[-._]/).some((seg) => GENERATED_NAME_SEGMENTS.has(seg.toLowerCase()));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const BUNDLER_TOOLS_RE = /\b(esbuild|webpack|rollup|parcel|vite|tsc|swc|rspack)\b/;
|
|
41
|
+
const OUTPUT_FLAG_RES = [
|
|
42
|
+
/--outfile[= ]+["']?([^\s"']+)/,
|
|
43
|
+
/--outdir(?:ir)?[= ]+["']?([^\s"']+)/i,
|
|
44
|
+
/--output(?:-path)?[= ]+["']?([^\s"']+)/,
|
|
45
|
+
/--file[= ]+["']?([^\s"']+)/,
|
|
46
|
+
/(?:^|\s)-o\s+["']?([^\s"']+)/,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Recognizes a package.json script that invokes a known bundler/compiler CLI
|
|
51
|
+
* and declares its output via a real, literal flag (`--outfile=X`, `-o X`,
|
|
52
|
+
* etc.) — the strongest possible signal a file is generated, since the
|
|
53
|
+
* project's OWN build configuration says so, not a naming guess. This is
|
|
54
|
+
* what catches a real bundler output whose filename doesn't happen to match
|
|
55
|
+
* any naming convention at all.
|
|
56
|
+
*/
|
|
57
|
+
export function detectBundlerOutputs(pkg) {
|
|
58
|
+
const outputs = [];
|
|
59
|
+
for (const [scriptName, cmd] of Object.entries(pkg?.scripts ?? {})) {
|
|
60
|
+
if (typeof cmd !== "string" || !BUNDLER_TOOLS_RE.test(cmd)) continue;
|
|
61
|
+
for (const re of OUTPUT_FLAG_RES) {
|
|
62
|
+
const m = re.exec(cmd);
|
|
63
|
+
if (m) outputs.push({ path: path.posix.normalize(m[1].replace(/^["']|["']$/g, "")), script: scriptName });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return outputs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const BUNDLER_CONFIG_FILES = [
|
|
70
|
+
"webpack.config.js", "webpack.config.cjs", "webpack.config.mjs", "webpack.config.ts",
|
|
71
|
+
"rollup.config.js", "rollup.config.cjs", "rollup.config.mjs", "rollup.config.ts",
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** Resolves `path.join/resolve(__dirname, "lit", ...)` to its literal parts, or a plain StringLiteral to its value. */
|
|
75
|
+
function resolveLiteralOrDirnamePath(valueNode) {
|
|
76
|
+
if (valueNode?.type === "StringLiteral") return valueNode.value;
|
|
77
|
+
if (valueNode?.type === "CallExpression") {
|
|
78
|
+
const callee = valueNode.callee;
|
|
79
|
+
const isPathJoinOrResolve = callee?.type === "MemberExpression" && callee.object?.name === "path" &&
|
|
80
|
+
(callee.property?.name === "join" || callee.property?.name === "resolve");
|
|
81
|
+
if (!isPathJoinOrResolve) return null;
|
|
82
|
+
const args = valueNode.arguments ?? [];
|
|
83
|
+
if (!args.some((a) => a.type === "Identifier" && a.name === "__dirname")) return null;
|
|
84
|
+
const literalParts = args.filter((a) => a.type === "StringLiteral").map((a) => a.value);
|
|
85
|
+
return literalParts.length ? literalParts.join("/") : null;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* webpack/rollup declare their output in a config FILE, not an npm-script
|
|
92
|
+
* flag — a project building through `webpack --config webpack.config.js`
|
|
93
|
+
* gives detectBundlerOutputs (script-string parsing) nothing to find, so its
|
|
94
|
+
* bundle would be scanned as source, the exact bug just fixed for esbuild.
|
|
95
|
+
* Same verification model as detectViteEntries: anchor on the literal
|
|
96
|
+
* `output` key, only trust plain string literals or the well-known
|
|
97
|
+
* `path.join/resolve(__dirname, ...)` pattern — anything dynamic is left
|
|
98
|
+
* undetected rather than guessed at.
|
|
99
|
+
* webpack: output: { path: path.resolve(__dirname, "electron"), filename: "main.js" }
|
|
100
|
+
* rollup: output: { file: "electron/bundle.cjs" } or output: { dir: "out" } (arrays too)
|
|
101
|
+
*/
|
|
102
|
+
export function detectConfigBundlerOutputs(rootDir) {
|
|
103
|
+
const outputs = [];
|
|
104
|
+
for (const rel of BUNDLER_CONFIG_FILES) {
|
|
105
|
+
const abs = path.join(rootDir, rel);
|
|
106
|
+
if (!fs.existsSync(abs)) continue;
|
|
107
|
+
let ast;
|
|
108
|
+
try { ast = parse(fs.readFileSync(abs, "utf8"), BABEL_OPTS); } catch { continue; }
|
|
109
|
+
|
|
110
|
+
traverse(ast, {
|
|
111
|
+
ObjectProperty(p) {
|
|
112
|
+
const keyName = p.node.key?.name ?? p.node.key?.value;
|
|
113
|
+
if (keyName !== "output") return;
|
|
114
|
+
const outputObjs = p.node.value.type === "ArrayExpression"
|
|
115
|
+
? p.node.value.elements.filter((el) => el?.type === "ObjectExpression")
|
|
116
|
+
: p.node.value.type === "ObjectExpression" ? [p.node.value] : [];
|
|
117
|
+
|
|
118
|
+
for (const obj of outputObjs) {
|
|
119
|
+
const prop = (name) => obj.properties.find((pr) => (pr.key?.name ?? pr.key?.value) === name);
|
|
120
|
+
// rollup: `file` (single output) / `dir` (whole directory)
|
|
121
|
+
const file = resolveLiteralOrDirnamePath(prop("file")?.value);
|
|
122
|
+
if (file) { outputs.push({ path: path.posix.normalize(file), script: rel }); continue; }
|
|
123
|
+
const dir = resolveLiteralOrDirnamePath(prop("dir")?.value);
|
|
124
|
+
if (dir) { outputs.push({ path: path.posix.normalize(dir), script: rel }); continue; }
|
|
125
|
+
// webpack: `path` (directory) + optional `filename`
|
|
126
|
+
const outPath = resolveLiteralOrDirnamePath(prop("path")?.value);
|
|
127
|
+
if (!outPath) continue;
|
|
128
|
+
const filename = prop("filename")?.value?.type === "StringLiteral" ? prop("filename").value.value : null;
|
|
129
|
+
// a webpack filename with substitution tokens ([name].js etc.) means
|
|
130
|
+
// we can't name the exact file — fall back to the output directory,
|
|
131
|
+
// which is still fully declared, not guessed.
|
|
132
|
+
const exact = filename && !filename.includes("[") ? path.posix.join(outPath, filename) : outPath;
|
|
133
|
+
outputs.push({ path: path.posix.normalize(exact), script: rel });
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return outputs;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Filename convention, a declared bundler output (npm-script flag OR
|
|
143
|
+
* webpack/rollup config file), or an explicit header marker — never a
|
|
144
|
+
* size/minification guess. `bundlerOutputs` (from detectBundlerOutputs +
|
|
145
|
+
* detectConfigBundlerOutputs) is optional; passing it in lets a file be
|
|
146
|
+
* recognized as generated even with no naming convention and no header
|
|
147
|
+
* comment at all (common for esbuild/webpack output, which often has neither).
|
|
148
|
+
*/
|
|
149
|
+
export function isGeneratedArtifact(rootDir, relFile, bundlerOutputs = []) {
|
|
150
|
+
if (isGeneratedFilename(relFile)) return { generated: true, reason: `filename convention (${path.posix.basename(relFile)})` };
|
|
151
|
+
const declared = bundlerOutputs.find((o) => o.path === relFile || relFile.startsWith(`${o.path}/`));
|
|
152
|
+
if (declared) return { generated: true, reason: `declared build output of "${declared.script}"` };
|
|
153
|
+
let head;
|
|
154
|
+
try { head = fs.readFileSync(path.join(rootDir, relFile), "utf8").slice(0, 300); } catch { return { generated: false }; }
|
|
155
|
+
const m = GENERATED_HEADER_RE.exec(head);
|
|
156
|
+
if (m) return { generated: true, reason: `header comment ("${m[0]}")` };
|
|
157
|
+
return { generated: false };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function allAncestorDirs(fileSet) {
|
|
161
|
+
const dirs = new Set();
|
|
162
|
+
for (const f of fileSet) {
|
|
163
|
+
let dir = path.posix.dirname(f);
|
|
164
|
+
while (dir && dir !== ".") { dirs.add(dir); dir = path.posix.dirname(dir); }
|
|
165
|
+
}
|
|
166
|
+
return dirs;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `<something>.load(path.join(__dirname, "tools"))`-style runtime directory
|
|
171
|
+
* loaders: any call whose argument resolves — via the same verified
|
|
172
|
+
* `path.join(__dirname, ...)`/`path.resolve(__dirname, ...)` pattern already
|
|
173
|
+
* used for Electron preload resolution — to a real DIRECTORY (not a single
|
|
174
|
+
* file) in the project. We record the exact call site as evidence; we do
|
|
175
|
+
* not attempt to guess which files inside actually get loaded.
|
|
176
|
+
*/
|
|
177
|
+
export function detectDynamicDirectoryReferences(rootDir, files, fileSet) {
|
|
178
|
+
const dirSet = allAncestorDirs(fileSet);
|
|
179
|
+
const results = [];
|
|
180
|
+
for (const f of files) {
|
|
181
|
+
const relFile = f.file.split(path.sep).join("/");
|
|
182
|
+
if (!/\.(js|ts|jsx|tsx|mjs|cjs|mts|cts)$/.test(relFile)) continue;
|
|
183
|
+
let raw;
|
|
184
|
+
try { raw = fs.readFileSync(path.join(rootDir, relFile), "utf8"); } catch { continue; }
|
|
185
|
+
if (!raw.includes("__dirname")) continue; // cheap pre-filter, not the verification itself
|
|
186
|
+
let ast;
|
|
187
|
+
try { ast = parse(raw, BABEL_OPTS); } catch { continue; }
|
|
188
|
+
const fileDirPosix = path.posix.dirname(relFile);
|
|
189
|
+
|
|
190
|
+
traverse(ast, {
|
|
191
|
+
CallExpression(p) {
|
|
192
|
+
const callee = p.node.callee;
|
|
193
|
+
const isPathJoinOrResolve = callee?.type === "MemberExpression" && callee.object?.name === "path" &&
|
|
194
|
+
(callee.property?.name === "join" || callee.property?.name === "resolve");
|
|
195
|
+
if (!isPathJoinOrResolve) return;
|
|
196
|
+
const args = p.node.arguments ?? [];
|
|
197
|
+
if (!args.some((a) => a.type === "Identifier" && a.name === "__dirname")) return;
|
|
198
|
+
const literalParts = args.filter((a) => a.type === "StringLiteral").map((a) => a.value);
|
|
199
|
+
if (!literalParts.length) return;
|
|
200
|
+
const resolved = path.posix.normalize(path.posix.join(fileDirPosix, literalParts.join("/")));
|
|
201
|
+
if (dirSet.has(resolved) && !fileSet.has(resolved)) {
|
|
202
|
+
results.push({ dir: resolved, referencedFrom: relFile, line: p.node.loc?.start.line ?? null });
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return results;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Template-literal dynamic loads: `import(\`./plugins/${name}.js\`)` and
|
|
212
|
+
* `require(\`./commands/${cmd}.js\`)` — the classic plugin/command-registry
|
|
213
|
+
* pattern. Static analysis cannot know which file gets loaded, but the
|
|
214
|
+
* literal PREFIX before the first interpolation names the directory being
|
|
215
|
+
* loaded from, and that part is fully verifiable: we resolve it relative to
|
|
216
|
+
* the importing file and only report it when it is a real directory that
|
|
217
|
+
* contains project files. Same design rule as every other detector here —
|
|
218
|
+
* verified against real AST structure and a real directory, or nothing.
|
|
219
|
+
*/
|
|
220
|
+
export function detectDynamicImportPrefixes(rootDir, files, fileSet) {
|
|
221
|
+
const dirSet = allAncestorDirs(fileSet);
|
|
222
|
+
const results = [];
|
|
223
|
+
for (const f of files) {
|
|
224
|
+
const relFile = f.file.split(path.sep).join("/");
|
|
225
|
+
if (!/\.(js|ts|jsx|tsx|mjs|cjs|mts|cts)$/.test(relFile)) continue;
|
|
226
|
+
let raw;
|
|
227
|
+
try { raw = fs.readFileSync(path.join(rootDir, relFile), "utf8"); } catch { continue; }
|
|
228
|
+
if (!raw.includes("import(") && !raw.includes("require(")) continue; // cheap pre-filter, not the verification itself
|
|
229
|
+
let ast;
|
|
230
|
+
try { ast = parse(raw, BABEL_OPTS); } catch { continue; }
|
|
231
|
+
const fileDirPosix = path.posix.dirname(relFile);
|
|
232
|
+
|
|
233
|
+
// only template literals WITH interpolation — a fully-static specifier
|
|
234
|
+
// is ordinary import resolution's job, not a dynamic edge
|
|
235
|
+
const checkSpecifier = (arg, node, via) => {
|
|
236
|
+
if (arg?.type !== "TemplateLiteral" || !arg.expressions?.length) return;
|
|
237
|
+
const prefix = arg.quasis?.[0]?.value?.cooked ?? "";
|
|
238
|
+
if (!prefix.startsWith("./") && !prefix.startsWith("../")) return; // bare/absolute specifiers can't be verified against the project tree
|
|
239
|
+
const dirPart = prefix.includes("/") ? prefix.slice(0, prefix.lastIndexOf("/")) : ".";
|
|
240
|
+
const resolved = path.posix.normalize(path.posix.join(fileDirPosix, dirPart));
|
|
241
|
+
if (dirSet.has(resolved)) {
|
|
242
|
+
results.push({ dir: resolved, referencedFrom: relFile, line: node.loc?.start.line ?? null, via });
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
traverse(ast, {
|
|
247
|
+
// Babel 8 parses `import(x)` as its own ImportExpression node
|
|
248
|
+
ImportExpression(p) {
|
|
249
|
+
checkSpecifier(p.node.source, p.node, "dynamic-import-template");
|
|
250
|
+
},
|
|
251
|
+
CallExpression(p) {
|
|
252
|
+
const callee = p.node.callee;
|
|
253
|
+
// Babel 7 shape for `import(x)` was CallExpression with an Import callee
|
|
254
|
+
if (callee?.type === "Import") return checkSpecifier(p.node.arguments?.[0], p.node, "dynamic-import-template");
|
|
255
|
+
if (callee?.type === "Identifier" && callee.name === "require") return checkSpecifier(p.node.arguments?.[0], p.node, "require-template");
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return results;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Vite's `import.meta.glob("./modules/*.js")` — the files matching the glob
|
|
264
|
+
* really are bundled and loaded (lazily) at runtime, but no ImportDeclaration
|
|
265
|
+
* ever names them. AST-verified shape: a call whose callee is
|
|
266
|
+
* `import.meta.glob` (MetaProperty + MemberExpression, not a regex guess)
|
|
267
|
+
* with literal string glob argument(s). Matched against the real file set
|
|
268
|
+
* with the same deterministic globToRegex used for annotations — a glob that
|
|
269
|
+
* matches nothing detects nothing.
|
|
270
|
+
*/
|
|
271
|
+
export function detectImportMetaGlobs(rootDir, files, fileSet) {
|
|
272
|
+
const results = [];
|
|
273
|
+
for (const f of files) {
|
|
274
|
+
const relFile = f.file.split(path.sep).join("/");
|
|
275
|
+
if (!/\.(js|ts|jsx|tsx|mjs|cjs|mts|cts)$/.test(relFile)) continue;
|
|
276
|
+
let raw;
|
|
277
|
+
try { raw = fs.readFileSync(path.join(rootDir, relFile), "utf8"); } catch { continue; }
|
|
278
|
+
if (!raw.includes("import.meta.glob")) continue; // cheap pre-filter, not the verification itself
|
|
279
|
+
let ast;
|
|
280
|
+
try { ast = parse(raw, BABEL_OPTS); } catch { continue; }
|
|
281
|
+
const fileDirPosix = path.posix.dirname(relFile);
|
|
282
|
+
|
|
283
|
+
traverse(ast, {
|
|
284
|
+
CallExpression(p) {
|
|
285
|
+
const callee = p.node.callee;
|
|
286
|
+
const isImportMetaGlob = callee?.type === "MemberExpression" &&
|
|
287
|
+
callee.object?.type === "MetaProperty" &&
|
|
288
|
+
callee.object.meta?.name === "import" && callee.object.property?.name === "meta" &&
|
|
289
|
+
callee.property?.name === "glob";
|
|
290
|
+
if (!isImportMetaGlob) return;
|
|
291
|
+
const arg = p.node.arguments?.[0];
|
|
292
|
+
const patterns = arg?.type === "StringLiteral" ? [arg.value]
|
|
293
|
+
: arg?.type === "ArrayExpression" ? arg.elements.filter((el) => el?.type === "StringLiteral").map((el) => el.value)
|
|
294
|
+
: [];
|
|
295
|
+
for (const pattern of patterns) {
|
|
296
|
+
if (!pattern.startsWith("./") && !pattern.startsWith("../") && !pattern.startsWith("/")) continue;
|
|
297
|
+
const resolved = pattern.startsWith("/")
|
|
298
|
+
? pattern.slice(1) // Vite: leading / = project root
|
|
299
|
+
: path.posix.normalize(path.posix.join(fileDirPosix, pattern));
|
|
300
|
+
const re = globToRegex(resolved);
|
|
301
|
+
for (const file of fileSet) {
|
|
302
|
+
if (re.test(file)) results.push({ file, referencedFrom: relFile, line: p.node.loc?.start.line ?? null, via: `import.meta.glob("${pattern}")` });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return results;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* `new Worker(new URL("./worker.js", import.meta.url))` — the standard
|
|
313
|
+
* bundler-recognized worker pattern. The worker file is genuinely executed
|
|
314
|
+
* at runtime but never appears in an import statement, so it otherwise reads
|
|
315
|
+
* as orphaned. AST-verified: NewExpression Worker/SharedWorker whose first
|
|
316
|
+
* argument is `new URL(<string literal>, import.meta.url)`, resolved relative
|
|
317
|
+
* to the referencing file and only reported when the target file exists.
|
|
318
|
+
*/
|
|
319
|
+
export function detectWorkerUrls(rootDir, files, fileSet) {
|
|
320
|
+
const results = [];
|
|
321
|
+
for (const f of files) {
|
|
322
|
+
const relFile = f.file.split(path.sep).join("/");
|
|
323
|
+
if (!/\.(js|ts|jsx|tsx|mjs|cjs|mts|cts)$/.test(relFile)) continue;
|
|
324
|
+
let raw;
|
|
325
|
+
try { raw = fs.readFileSync(path.join(rootDir, relFile), "utf8"); } catch { continue; }
|
|
326
|
+
if (!raw.includes("Worker")) continue; // cheap pre-filter, not the verification itself
|
|
327
|
+
let ast;
|
|
328
|
+
try { ast = parse(raw, BABEL_OPTS); } catch { continue; }
|
|
329
|
+
const fileDirPosix = path.posix.dirname(relFile);
|
|
330
|
+
|
|
331
|
+
traverse(ast, {
|
|
332
|
+
NewExpression(p) {
|
|
333
|
+
const calleeName = p.node.callee?.type === "Identifier" ? p.node.callee.name : null;
|
|
334
|
+
if (calleeName !== "Worker" && calleeName !== "SharedWorker") return;
|
|
335
|
+
const arg = p.node.arguments?.[0];
|
|
336
|
+
const isNewUrl = arg?.type === "NewExpression" && arg.callee?.type === "Identifier" && arg.callee.name === "URL";
|
|
337
|
+
if (!isNewUrl) return;
|
|
338
|
+
const spec = arg.arguments?.[0];
|
|
339
|
+
const base = arg.arguments?.[1];
|
|
340
|
+
const isImportMetaUrl = base?.type === "MemberExpression" &&
|
|
341
|
+
base.object?.type === "MetaProperty" && base.property?.name === "url";
|
|
342
|
+
if (spec?.type !== "StringLiteral" || !isImportMetaUrl) return;
|
|
343
|
+
const resolved = path.posix.normalize(path.posix.join(fileDirPosix, spec.value));
|
|
344
|
+
if (fileSet.has(resolved)) {
|
|
345
|
+
results.push({ file: resolved, referencedFrom: relFile, line: p.node.loc?.start.line ?? null, via: `new ${calleeName}(new URL(...))` });
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return results;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const TEST_GLOB_CONFIGS = [
|
|
354
|
+
"playwright.config.ts", "playwright.config.js", "playwright.config.mjs", "playwright.config.cjs",
|
|
355
|
+
"vitest.config.ts", "vitest.config.js", "vitest.config.mjs", "vitest.config.cjs",
|
|
356
|
+
"jest.config.js", "jest.config.ts", "jest.config.cjs", "jest.config.mjs",
|
|
357
|
+
"cypress.config.ts", "cypress.config.js",
|
|
358
|
+
];
|
|
359
|
+
const TEST_GLOB_KEYS = new Set(["testDir", "testMatch", "include", "roots", "specPattern", "testRegex"]);
|
|
360
|
+
|
|
361
|
+
/** Everything up to the first glob metacharacter, with any trailing partial segment trimmed. */
|
|
362
|
+
function staticPrefixOfGlob(globStr) {
|
|
363
|
+
const idx = globStr.search(/[*{]/);
|
|
364
|
+
if (idx === -1) return globStr.replace(/\/$/, ""); // no glob metachar at all — the whole string is a literal path
|
|
365
|
+
const prefix = globStr.slice(0, idx);
|
|
366
|
+
return prefix.replace(/\/[^/]*$/, ""); // trim the partial segment before the wildcard, e.g. "tests/**" -> "tests"
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Playwright/Vitest/Jest/Cypress configs declare WHERE their specs live as a
|
|
371
|
+
* string or glob value (`testDir`, `include`, ...) — the runner globs that
|
|
372
|
+
* directory at runtime, it never imports the spec files, so the existing
|
|
373
|
+
* tooling-config entry-point registration (frameworkEntries.js) doesn't help
|
|
374
|
+
* here. We only trust literal string values under a fixed, known key set.
|
|
375
|
+
*/
|
|
376
|
+
export function detectTestGlobDirectories(rootDir, fileSet) {
|
|
377
|
+
const results = [];
|
|
378
|
+
for (const rel of TEST_GLOB_CONFIGS) {
|
|
379
|
+
const abs = path.join(rootDir, rel);
|
|
380
|
+
if (!fs.existsSync(abs)) continue;
|
|
381
|
+
let ast;
|
|
382
|
+
try { ast = parse(fs.readFileSync(abs, "utf8"), BABEL_OPTS); } catch { continue; }
|
|
383
|
+
|
|
384
|
+
traverse(ast, {
|
|
385
|
+
ObjectProperty(p) {
|
|
386
|
+
const keyName = p.node.key?.name ?? p.node.key?.value;
|
|
387
|
+
if (!TEST_GLOB_KEYS.has(keyName)) return;
|
|
388
|
+
const value = p.node.value;
|
|
389
|
+
const literals = [];
|
|
390
|
+
if (value.type === "StringLiteral") literals.push(value.value);
|
|
391
|
+
else if (value.type === "ArrayExpression") for (const el of value.elements) if (el?.type === "StringLiteral") literals.push(el.value);
|
|
392
|
+
|
|
393
|
+
for (const lit of literals) {
|
|
394
|
+
const prefix = staticPrefixOfGlob(lit);
|
|
395
|
+
if (!prefix) continue;
|
|
396
|
+
const resolved = path.posix.normalize(path.posix.join(".", prefix));
|
|
397
|
+
if ([...fileSet].some((f) => f === resolved || f.startsWith(`${resolved}/`))) {
|
|
398
|
+
results.push({ dir: resolved, referencedFrom: rel, key: keyName, line: p.node.loc?.start.line ?? null });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return results;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Tiny deterministic glob: `**` crosses directories, `*` stays within a segment, everything else literal. */
|
|
408
|
+
export function globToRegex(pattern) {
|
|
409
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
410
|
+
const body = escaped
|
|
411
|
+
.replace(/\*\*\//g, "ANYDIR")
|
|
412
|
+
.replace(/\*\*/g, "ANY")
|
|
413
|
+
.replace(/\*/g, "[^/]*")
|
|
414
|
+
.replace(/ANYDIR/g, "(?:.*/)?")
|
|
415
|
+
.replace(/ANY/g, ".*");
|
|
416
|
+
return new RegExp(`^${body}$`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* User-asserted classifications from .mapdrc `project.annotations` — the
|
|
421
|
+
* honest answer for things static analysis structurally cannot know (a
|
|
422
|
+
* timestamped results directory, a plugin folder loaded by a pattern mapd
|
|
423
|
+
* doesn't recognize). Mapd never guesses these; the user states them, and
|
|
424
|
+
* every downstream mention is labeled as user-asserted, never as detected.
|
|
425
|
+
* Returns [{ file, classification, pattern }] for files matching any pattern.
|
|
426
|
+
*/
|
|
427
|
+
export function applyAnnotations(fileSet, annotations = {}) {
|
|
428
|
+
// a value is either a bare classification string or { classification, reason?, source?, date? }
|
|
429
|
+
const compiled = Object.entries(annotations).map(([pattern, raw]) => ({
|
|
430
|
+
pattern,
|
|
431
|
+
classification: typeof raw === "string" ? raw : raw?.classification,
|
|
432
|
+
re: globToRegex(pattern),
|
|
433
|
+
}));
|
|
434
|
+
if (!compiled.length) return [];
|
|
435
|
+
const matches = [];
|
|
436
|
+
for (const file of fileSet) {
|
|
437
|
+
const hit = compiled.find((c) => c.re.test(file));
|
|
438
|
+
if (hit) matches.push({ file, classification: hit.classification, pattern: hit.pattern });
|
|
439
|
+
}
|
|
440
|
+
return matches;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Classifies generated-file status for EVERY file in the project, not just
|
|
445
|
+
* unreachable ones — a bundler output is generated regardless of whether
|
|
446
|
+
* something else in the project happens to import/require it. This is what
|
|
447
|
+
* lets modernize.js exclude a real, reachable bundle (e.g. one loaded by an
|
|
448
|
+
* Electron main process) from its scan, not just orphan-cluster.
|
|
449
|
+
* `annotations` adds user-asserted "generated" classifications on top of the
|
|
450
|
+
* detected ones, labeled as such.
|
|
451
|
+
*/
|
|
452
|
+
export function detectGeneratedFiles(rootDir, fileSet, pkg, annotations = {}) {
|
|
453
|
+
const bundlerOutputs = [...detectBundlerOutputs(pkg), ...detectConfigBundlerOutputs(rootDir)];
|
|
454
|
+
const annotated = new Map(
|
|
455
|
+
applyAnnotations(fileSet, annotations)
|
|
456
|
+
.filter((a) => a.classification === "generated")
|
|
457
|
+
.map((a) => [a.file, a.pattern]),
|
|
458
|
+
);
|
|
459
|
+
const generated = [];
|
|
460
|
+
for (const file of fileSet) {
|
|
461
|
+
if (annotated.has(file)) {
|
|
462
|
+
generated.push({ file, reason: `user annotation in .mapdrc ("${annotated.get(file)}": "generated")` });
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const result = isGeneratedArtifact(rootDir, file, bundlerOutputs);
|
|
466
|
+
if (result.generated) generated.push({ file, reason: result.reason });
|
|
467
|
+
}
|
|
468
|
+
return generated;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The single entry point graph.js calls: classifies every file that ISN'T
|
|
473
|
+
* already statically reachable (not in `covered`) and isn't a test file (the
|
|
474
|
+
* two categories graph.js already computes) into: generated artifact,
|
|
475
|
+
* dynamically-loaded/unverifiable, user-asserted intentional-dormant,
|
|
476
|
+
* heuristic-unverified (parsed by a heuristic language adapter, so orphan
|
|
477
|
+
* status structurally cannot be asserted), or truly orphaned. Every
|
|
478
|
+
* non-orphan classification carries its evidence (the exact file/line or
|
|
479
|
+
* annotation that justified excluding it) — never a bare, unexplained
|
|
480
|
+
* exclusion. `generatedFiles` is detectGeneratedFiles' project-wide result,
|
|
481
|
+
* passed in so it's computed once.
|
|
482
|
+
*/
|
|
483
|
+
export function classifyUncoveredFiles(rootDir, files, fileSet, uncovered, generatedFiles, annotations = {}) {
|
|
484
|
+
const generatedSet = new Set(generatedFiles.map((g) => g.file));
|
|
485
|
+
const generatedReasons = new Map(generatedFiles.map((g) => [g.file, g.reason]));
|
|
486
|
+
const parserKindByFile = new Map(files.map((f) => [f.file.split(path.sep).join("/"), f.parserKind ?? "ast"]));
|
|
487
|
+
|
|
488
|
+
const dynamicRefs = [
|
|
489
|
+
...detectDynamicDirectoryReferences(rootDir, files, fileSet),
|
|
490
|
+
...detectDynamicImportPrefixes(rootDir, files, fileSet),
|
|
491
|
+
...detectTestGlobDirectories(rootDir, fileSet),
|
|
492
|
+
];
|
|
493
|
+
const dynamicDirs = new Map(); // dir -> evidence[]
|
|
494
|
+
for (const ref of dynamicRefs) {
|
|
495
|
+
if (!dynamicDirs.has(ref.dir)) dynamicDirs.set(ref.dir, []);
|
|
496
|
+
dynamicDirs.get(ref.dir).push(ref);
|
|
497
|
+
}
|
|
498
|
+
// file-level dynamic references: import.meta.glob matches and Worker URLs
|
|
499
|
+
// name EXACT files (verified to exist), not just a directory
|
|
500
|
+
const dynamicFiles = new Map(); // file -> evidence[]
|
|
501
|
+
for (const ref of [...detectImportMetaGlobs(rootDir, files, fileSet), ...detectWorkerUrls(rootDir, files, fileSet)]) {
|
|
502
|
+
if (!dynamicFiles.has(ref.file)) dynamicFiles.set(ref.file, []);
|
|
503
|
+
dynamicFiles.get(ref.file).push(ref);
|
|
504
|
+
}
|
|
505
|
+
const isUnderDynamicDir = (file) => {
|
|
506
|
+
if (dynamicFiles.has(file)) return dynamicFiles.get(file);
|
|
507
|
+
for (const [dir, evidence] of dynamicDirs) if (file === dir || file.startsWith(`${dir}/`)) return evidence;
|
|
508
|
+
return null;
|
|
509
|
+
};
|
|
510
|
+
const annotated = applyAnnotations(fileSet, annotations);
|
|
511
|
+
const annotatedDynamic = new Map(annotated.filter((a) => a.classification === "dynamically-loaded").map((a) => [a.file, a.pattern]));
|
|
512
|
+
const annotatedDormant = new Map(annotated.filter((a) => a.classification === "intentional-dormant").map((a) => [a.file, a.pattern]));
|
|
513
|
+
|
|
514
|
+
const generatedArtifacts = [];
|
|
515
|
+
const dynamicallyLoaded = [];
|
|
516
|
+
const intentionalDormant = [];
|
|
517
|
+
const heuristicUnverified = [];
|
|
518
|
+
const trulyOrphaned = [];
|
|
519
|
+
|
|
520
|
+
for (const file of uncovered) {
|
|
521
|
+
if (generatedSet.has(file)) { generatedArtifacts.push({ file, reason: generatedReasons.get(file) }); continue; }
|
|
522
|
+
if (annotatedDormant.has(file)) {
|
|
523
|
+
intentionalDormant.push({ file, evidence: [{ userAnnotation: `.mapdrc "${annotatedDormant.get(file)}": "intentional-dormant"` }] });
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (annotatedDynamic.has(file)) {
|
|
527
|
+
dynamicallyLoaded.push({ file, evidence: [{ userAnnotation: `.mapdrc "${annotatedDynamic.get(file)}": "dynamically-loaded"` }] });
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const evidence = isUnderDynamicDir(file);
|
|
531
|
+
if (evidence) { dynamicallyLoaded.push({ file, evidence }); continue; }
|
|
532
|
+
if (parserKindByFile.get(file) === "heuristic") {
|
|
533
|
+
// heuristic import tracing is partial by construction — "no edge found"
|
|
534
|
+
// is not evidence of death, so these are disclosed as unverifiable,
|
|
535
|
+
// never asserted orphaned
|
|
536
|
+
heuristicUnverified.push({ file, reason: "parsed by heuristic language adapter — import tracing is partial, orphan status cannot be asserted" });
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
trulyOrphaned.push(file);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return { generatedArtifacts, dynamicallyLoaded, intentionalDormant, heuristicUnverified, trulyOrphaned: trulyOrphaned.sort() };
|
|
543
|
+
}
|