@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,536 @@
1
+ /**
2
+ * parser.js — Deterministic AST extraction (adapter #1: JavaScript/TypeScript).
3
+ *
4
+ * DESIGN RULE: Nothing in this file may call an LLM. The map is ground truth,
5
+ * extracted from ASTs. Any file we cannot parse is recorded as unparsed and
6
+ * *lowers* the derived confidence score — we never guess its contents.
7
+ *
8
+ * Adding a language = implementing this same interface (see ParserAdapter shape
9
+ * at bottom) with e.g. tree-sitter-python and registering it in ADAPTERS.
10
+ */
11
+
12
+ import { parse } from "@babel/parser";
13
+ import _traverse from "@babel/traverse";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import crypto from "node:crypto";
17
+ import { parsePolyglotFile, POLYGLOT_EXTENSIONS, KNOWN_UNSUPPORTED_EXTENSIONS } from "./polyglot.js";
18
+
19
+ const traverse = _traverse.default ?? _traverse;
20
+
21
+ const JS_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]);
22
+
23
+ const BABEL_OPTS = {
24
+ sourceType: "unambiguous",
25
+ errorRecovery: true,
26
+ plugins: ["typescript", "jsx", "decorators-legacy"], // Babel 8: class props / top-level await / import attributes are default
27
+ };
28
+
29
+ /** NestJS-style HTTP method decorators — verified against real Babel AST output, not assumed. */
30
+ const ROUTE_DECORATORS = new Set(["Get", "Post", "Put", "Delete", "Patch", "Options", "Head", "All"]);
31
+
32
+ function decoratorCalleeName(decorator) {
33
+ const expr = decorator.expression;
34
+ if (expr.type === "Identifier") return expr.name;
35
+ if (expr.type === "CallExpression" && expr.callee?.type === "Identifier") return expr.callee.name;
36
+ return null;
37
+ }
38
+
39
+ /** Trivial functions (getters, one-liners) below this size produce meaningless "duplicate" noise across nearly any codebase. */
40
+ const MIN_SHAPE_HASH_LINES = 4;
41
+
42
+ /**
43
+ * Structural "shape hash" for near-duplicate (Type-2 clone) detection: a
44
+ * deterministic hash of the function's AST node-type/operator sequence, with
45
+ * locally-declared identifiers (parameters, variables, catch params — incl.
46
+ * nested function scopes within the body) normalized to positional
47
+ * placeholders so a consistent variable rename doesn't change the hash.
48
+ * Literal VALUES are reduced to their type tag, not their content — two
49
+ * functions differing only in a string/number constant still match, which is
50
+ * the standard definition of a Type-2 clone. Property names and called
51
+ * function/method names ARE kept literal: calling a different function is a
52
+ * real behavioral difference, not a rename.
53
+ *
54
+ * Returns null for functions shorter than MIN_SHAPE_HASH_LINES — never
55
+ * computed, never a fabricated hash for a function too small to meaningfully
56
+ * compare.
57
+ */
58
+ function computeShapeHash(fnPath, locLines) {
59
+ if (locLines < MIN_SHAPE_HASH_LINES) return null;
60
+
61
+ const localNames = new Set();
62
+ for (const name of Object.keys(fnPath.scope.bindings)) localNames.add(name);
63
+ fnPath.traverse({
64
+ Scopable(innerPath) {
65
+ for (const name of Object.keys(innerPath.scope.bindings)) localNames.add(name);
66
+ },
67
+ });
68
+
69
+ const localOrdinal = new Map();
70
+ const tokens = [];
71
+ const OPERATOR_NODE_TYPES = new Set(["BinaryExpression", "LogicalExpression", "AssignmentExpression", "UnaryExpression", "UpdateExpression"]);
72
+ const LITERAL_NODE_TYPES = new Set(["StringLiteral", "NumericLiteral", "BooleanLiteral"]);
73
+
74
+ const ownNameNode = fnPath.node.id ?? fnPath.node.key ?? null;
75
+
76
+ fnPath.traverse({
77
+ enter(p) {
78
+ const n = p.node;
79
+ if (n === ownNameNode) return; // the function/method's own declared name is not part of its body's shape
80
+ if (n.type === "Identifier") {
81
+ if (localNames.has(n.name)) {
82
+ if (!localOrdinal.has(n.name)) localOrdinal.set(n.name, `VAR${localOrdinal.size + 1}`);
83
+ tokens.push(localOrdinal.get(n.name));
84
+ } else {
85
+ tokens.push(`ref:${n.name}`);
86
+ }
87
+ } else if (LITERAL_NODE_TYPES.has(n.type)) {
88
+ tokens.push(`lit:${n.type}`);
89
+ } else if (OPERATOR_NODE_TYPES.has(n.type)) {
90
+ tokens.push(`${n.type}:${n.operator}`);
91
+ } else {
92
+ tokens.push(n.type);
93
+ }
94
+ },
95
+ });
96
+
97
+ return crypto.createHash("sha1").update(tokens.join("|")).digest("hex").slice(0, 16);
98
+ }
99
+
100
+ /**
101
+ * Parse one file into a FileNode.
102
+ * @returns {{
103
+ * file: string, lang: "js", parsed: boolean, parseErrors: number,
104
+ * functions: Array<{name: string, exported: boolean, async: boolean, loc: number, params: number, calls: string[]}>,
105
+ * imports: Array<{source: string, names: string[]}>,
106
+ * exports: string[],
107
+ * loc: number
108
+ * }}
109
+ */
110
+ export function parseJsFile(absPath, relPath) {
111
+ const code = fs.readFileSync(absPath, "utf8");
112
+ const loc = code.split("\n").length;
113
+ const node = {
114
+ file: relPath, lang: "js", parsed: false, parseErrors: 0,
115
+ functions: [], imports: [], exports: [], loc,
116
+ moduleType: null, // "module" | "script" — detected, used by modernization scan
117
+ varCount: 0, // `var` declarations — legacy-pattern signal
118
+ decoratorRoutes: [], // NestJS-style @Get()/@Post()/etc. method decorators — see ROUTE_DECORATORS
119
+ };
120
+
121
+ let ast;
122
+ try {
123
+ ast = parse(code, BABEL_OPTS);
124
+ node.parseErrors = ast.errors?.length ?? 0;
125
+ node.parsed = true;
126
+ } catch {
127
+ return node; // unparsed file: recorded honestly, degrades confidence
128
+ }
129
+
130
+ const exportedNames = new Set();
131
+ /** identifier name -> its ObjectExpression node, for `const x = {...}; module.exports = x;` */
132
+ const objectLiterals = new Map();
133
+ /** name -> { calls:Set, async, loc, params, exported } */
134
+ const fns = new Map();
135
+
136
+ const addFn = (name, fnNode, exported) => {
137
+ if (!name) return;
138
+ const entry = fns.get(name) ?? {
139
+ name, calls: new Set(), async: !!fnNode.async,
140
+ loc: fnNode.loc ? fnNode.loc.end.line - fnNode.loc.start.line + 1 : 0,
141
+ params: fnNode.params?.length ?? 0, exported: false,
142
+ };
143
+ entry.exported = entry.exported || exported;
144
+ fns.set(name, entry);
145
+ return entry;
146
+ };
147
+
148
+ const calleeName = (callee) => {
149
+ if (!callee) return null;
150
+ if (callee.type === "Identifier") return callee.name;
151
+ if (callee.type === "ThisExpression") return "this"; // this.method() — resolved against the enclosing function's `receiver`, see graph.js
152
+ if (callee.type === "CallExpression") return "()"; // method on a call result, e.g. fetch(x).then
153
+ if (callee.type === "MemberExpression") {
154
+ const obj = calleeName(callee.object);
155
+ const prop = callee.property?.name ?? null;
156
+ return obj && prop ? `${obj}.${prop}` : prop;
157
+ }
158
+ return null;
159
+ };
160
+
161
+ /** X.prototype.method = <function-like> — l is the AssignmentExpression's left MemberExpression. */
162
+ const prototypeAssignmentTarget = (l) =>
163
+ l?.type === "MemberExpression" && l.object?.type === "MemberExpression" &&
164
+ l.object.property?.name === "prototype" && l.object.object?.type === "Identifier" && l.property?.name
165
+ ? { className: l.object.object.name, methodName: l.property.name }
166
+ : null;
167
+
168
+ /** Object.assign(X.prototype, { method(){}, other: function(){} }) — target is the call's first argument. */
169
+ const objectAssignPrototypeTarget = (calleeNode, targetArg) =>
170
+ calleeNode?.type === "MemberExpression" && calleeNode.object?.name === "Object" && calleeNode.property?.name === "assign" &&
171
+ targetArg?.type === "MemberExpression" && targetArg.property?.name === "prototype" && targetArg.object?.type === "Identifier"
172
+ ? targetArg.object.name
173
+ : null;
174
+
175
+ // Which function body are we inside? Track a stack.
176
+ const fnStack = [];
177
+
178
+ node.moduleType = ast.program.sourceType;
179
+
180
+ traverse(ast, {
181
+ VariableDeclaration(p) {
182
+ if (p.node.kind === "var") node.varCount++;
183
+ for (const dec of p.node.declarations) {
184
+ if (dec.id?.type === "Identifier" && dec.init?.type === "ObjectExpression") {
185
+ objectLiterals.set(dec.id.name, dec.init);
186
+ }
187
+ }
188
+ },
189
+ ImportDeclaration(p) {
190
+ node.imports.push({
191
+ source: p.node.source.value,
192
+ names: p.node.specifiers.map((s) => s.local.name),
193
+ // `import type { X }` is erased at compile time — it creates no runtime
194
+ // dependency. Flagged so test credit can tell "a test exercises this"
195
+ // from "a test borrows its types", which are not the same claim. Only
196
+ // present when true, so the common record shape is unchanged.
197
+ ...(p.node.importKind === "type" ||
198
+ (p.node.specifiers.length > 0 && p.node.specifiers.every((sp) => sp.importKind === "type"))
199
+ ? { typeOnly: true }
200
+ : {}),
201
+ });
202
+ },
203
+ // import("./x.js") with a STATIC string specifier is a real, resolvable
204
+ // module edge (lazy-loaded routes/modals are imported exactly this way) —
205
+ // without it the target looks orphaned. Template-literal dynamic imports
206
+ // stay reachability.js's job. Babel 8 parses import() as ImportExpression.
207
+ ImportExpression(p) {
208
+ if (p.node.source?.type === "StringLiteral") {
209
+ node.imports.push({ source: p.node.source.value, names: [] });
210
+ }
211
+ },
212
+ CallExpression(p) {
213
+ // require() imports — capture the bound names so CJS resolves like ESM
214
+ if (p.node.callee.name === "require" && p.node.arguments[0]?.type === "StringLiteral") {
215
+ const names = [];
216
+ const parent = p.parent;
217
+ if (parent?.type === "VariableDeclarator") {
218
+ if (parent.id.type === "Identifier") names.push(parent.id.name);
219
+ else if (parent.id.type === "ObjectPattern") {
220
+ for (const prop of parent.id.properties) {
221
+ if (prop.value?.type === "Identifier") names.push(prop.value.name);
222
+ else if (prop.argument?.type === "Identifier") names.push(prop.argument.name); // rest
223
+ }
224
+ }
225
+ } else if (parent?.type === "MemberExpression" && p.parentPath.parent?.type === "VariableDeclarator"
226
+ && p.parentPath.parent.id?.type === "Identifier") {
227
+ names.push(p.parentPath.parent.id.name); // const x = require('y').z
228
+ }
229
+ node.imports.push({ source: p.node.arguments[0].value, names });
230
+ }
231
+ const name = calleeName(p.node.callee);
232
+ if (name && fnStack.length) fnStack[fnStack.length - 1].calls.add(name);
233
+ },
234
+ AssignmentExpression(p) {
235
+ // CJS export surface: module.exports = ... / module.exports.x = ... / exports.x = ...
236
+ const l = p.node.left;
237
+ if (l?.type !== "MemberExpression") return;
238
+ const objName = l.object?.name ?? (l.object?.object?.name === "module" && l.object?.property?.name === "exports" ? "module.exports" : null);
239
+ if (objName === "exports" || objName === "module.exports") {
240
+ if (l.property?.name) exportedNames.add(l.property.name);
241
+ } else if (l.object?.name === "module" && l.property?.name === "exports") {
242
+ const r = p.node.right;
243
+ if (r.type === "ObjectExpression") {
244
+ for (const prop of r.properties) if (prop.key?.name) exportedNames.add(prop.key.name);
245
+ } else if (r.type === "Identifier") exportedNames.add(r.name);
246
+ else if (r.id?.name) exportedNames.add(r.id.name); // module.exports = function foo(){}
247
+ else if (r.type === "CallExpression" && r.callee?.name) exportedNames.add(r.callee.name); // = createX()
248
+ else exportedNames.add("default");
249
+ }
250
+ },
251
+ ExportNamedDeclaration(p) {
252
+ for (const s of p.node.specifiers ?? []) exportedNames.add(s.exported.name ?? s.exported.value);
253
+ const d = p.node.declaration;
254
+ if (d?.id?.name) exportedNames.add(d.id.name);
255
+ if (d?.declarations) for (const dec of d.declarations) if (dec.id?.name) exportedNames.add(dec.id.name);
256
+ // `export { x } from "./impl.js"` — a barrel re-export IS an import of
257
+ // ./impl.js; without this edge the implementation file falls out of
258
+ // every workflow and gets misreported as unreachable.
259
+ if (p.node.source?.value) {
260
+ node.imports.push({ source: p.node.source.value, names: p.node.specifiers.map((s) => s.local?.name ?? s.exported?.name).filter(Boolean) });
261
+ }
262
+ },
263
+ // `export * from "./wide.js"` — same barrel edge. The re-exported NAMES
264
+ // are statically unknowable here, so none are fabricated; only the
265
+ // verified file relationship is recorded.
266
+ ExportAllDeclaration(p) {
267
+ if (p.node.source?.value) node.imports.push({ source: p.node.source.value, names: [] });
268
+ },
269
+ ExportDefaultDeclaration(p) {
270
+ const d = p.node.declaration;
271
+ exportedNames.add(d?.id?.name ?? "default");
272
+ },
273
+ "FunctionDeclaration|FunctionExpression|ArrowFunctionExpression|ClassMethod|ObjectMethod": {
274
+ enter(p) {
275
+ const n = p.node;
276
+ let name = n.id?.name ?? n.key?.name ?? null;
277
+ let receiver = null;
278
+
279
+ // const foo = () => {} / const foo = function () {}
280
+ if (!name && p.parent?.type === "VariableDeclarator" && p.parent.id?.type === "Identifier") {
281
+ name = p.parent.id.name;
282
+ }
283
+
284
+ // X.prototype.method = function(){} / async function(){} / () => {}
285
+ if (p.parent?.type === "AssignmentExpression") {
286
+ const target = prototypeAssignmentTarget(p.parent.left);
287
+ if (target) { receiver = target.className; name = `${receiver}.prototype.${target.methodName}`; }
288
+ }
289
+
290
+ // Object.assign(X.prototype, { method(){}, other: function(){} })
291
+ if (!receiver) {
292
+ let keyName = n.key?.name ?? n.key?.value ?? null; // ObjectMethod carries its own key
293
+ let objExprPath = null;
294
+ if (p.parentPath?.isObjectExpression()) {
295
+ objExprPath = p.parentPath; // ObjectMethod directly inside the object literal
296
+ } else if (p.parentPath?.isObjectProperty() && p.parentPath.parentPath?.isObjectExpression()) {
297
+ keyName = p.parentPath.node.key?.name ?? p.parentPath.node.key?.value ?? keyName;
298
+ objExprPath = p.parentPath.parentPath; // `other: function(){}` — FunctionExpression as a property value
299
+ }
300
+ const callPath = objExprPath?.parentPath?.isCallExpression() ? objExprPath.parentPath : null;
301
+ if (callPath && keyName) {
302
+ const className = objectAssignPrototypeTarget(callPath.node.callee, callPath.node.arguments?.[0]);
303
+ if (className) { receiver = className; name = `${receiver}.prototype.${keyName}`; }
304
+ }
305
+ }
306
+
307
+ // NestJS-style @Get()/@Post()/etc. method decorators — a real, verified AST
308
+ // shape (ClassMethod.decorators[].expression), not a regex guess.
309
+ if (n.type === "ClassMethod" && Array.isArray(n.decorators)) {
310
+ const classPath = p.findParent((pp) => pp.isClassDeclaration() || pp.isClassExpression());
311
+ const className = classPath?.node.id?.name ?? null;
312
+ for (const decorator of n.decorators) {
313
+ const decoratorName = decoratorCalleeName(decorator);
314
+ if (decoratorName && ROUTE_DECORATORS.has(decoratorName)) {
315
+ node.decoratorRoutes.push({ class: className, method: n.key?.name ?? null, decorator: decoratorName });
316
+ }
317
+ }
318
+ }
319
+
320
+ // exported inline?
321
+ const exportedInline =
322
+ p.findParent((pp) => pp.isExportNamedDeclaration() || pp.isExportDefaultDeclaration()) != null;
323
+ const entry = addFn(name ?? `<anon:${n.loc?.start.line ?? "?"}>`, n, exportedInline);
324
+ if (entry) {
325
+ if (receiver) entry.receiver = receiver;
326
+ entry.shapeHash = computeShapeHash(p, entry.loc);
327
+ }
328
+ fnStack.push(entry ?? { calls: new Set() });
329
+ },
330
+ exit() { fnStack.pop(); },
331
+ },
332
+ });
333
+
334
+ // `const alpaca = { getPortfolioHistory() {...} }; module.exports = alpaca;` — the
335
+ // AssignmentExpression visitor above only saw the identifier "alpaca" being exported;
336
+ // resolve it back to the object literal it was declared with and export its properties
337
+ // too, so CJS's "declare an object, export the identifier" pattern isn't missed.
338
+ for (const name of [...exportedNames]) {
339
+ const obj = objectLiterals.get(name);
340
+ if (!obj) continue;
341
+ for (const prop of obj.properties) {
342
+ const key = prop.key?.name ?? prop.key?.value;
343
+ if (key) exportedNames.add(key);
344
+ }
345
+ }
346
+
347
+ for (const fn of fns.values()) {
348
+ fn.exported = fn.exported || exportedNames.has(fn.name);
349
+ node.functions.push({ ...fn, calls: [...fn.calls] });
350
+ }
351
+ node.exports = [...exportedNames];
352
+ return node;
353
+ }
354
+
355
+ // Directory names skipped at ANY depth no matter what .mapdrc says: installed
356
+ // dependencies are never project source. (.mapdrc excludes are root-anchored —
357
+ // "node_modules/**" alone let a nested app's packages/x/node_modules flood the map.)
358
+ export const ALWAYS_IGNORE = new Set(["node_modules"]);
359
+ export const DEFAULT_IGNORE = new Set(["node_modules", ".git", "dist", "build", "coverage", ".mapd", ".next", "out"]);
360
+
361
+ function normalizeRelPath(rel) {
362
+ return rel.split(path.sep).join("/").replace(/^\.\//, "");
363
+ }
364
+
365
+ function escapeRegExp(s) {
366
+ return s.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
367
+ }
368
+
369
+ function globToRegExp(pattern) {
370
+ const p = normalizeRelPath(String(pattern ?? "").trim());
371
+ if (!p) return /^$/;
372
+ let out = "^";
373
+ for (let i = 0; i < p.length; i++) {
374
+ const ch = p[i];
375
+ const next = p[i + 1];
376
+ if (ch === "*" && next === "*") {
377
+ const after = p[i + 2];
378
+ if (after === "/") {
379
+ out += "(?:.*/)?";
380
+ i += 2;
381
+ } else {
382
+ out += ".*";
383
+ i++;
384
+ }
385
+ } else if (ch === "*") {
386
+ out += "[^/]*";
387
+ } else if (ch === "?") {
388
+ out += "[^/]";
389
+ } else {
390
+ out += escapeRegExp(ch);
391
+ }
392
+ }
393
+ out += "$";
394
+ return new RegExp(out);
395
+ }
396
+
397
+ const globRegexCache = new Map();
398
+
399
+ function matchesGlob(rel, pattern) {
400
+ const normalizedRel = normalizeRelPath(rel).replace(/\/$/, "");
401
+ const normalizedPattern = normalizeRelPath(pattern).replace(/\/$/, "");
402
+ if (!normalizedPattern) return false;
403
+
404
+ // The common directory-subtree pattern should match the directory itself
405
+ // too, so "dist/**" excludes both "dist" and "dist/app.js".
406
+ if (normalizedPattern.endsWith("/**")) {
407
+ const dir = normalizedPattern.slice(0, -3);
408
+ if (normalizedRel === dir || normalizedRel.startsWith(`${dir}/`)) return true;
409
+ }
410
+
411
+ let re = globRegexCache.get(normalizedPattern);
412
+ if (!re) {
413
+ re = globToRegExp(normalizedPattern);
414
+ globRegexCache.set(normalizedPattern, re);
415
+ }
416
+ return re.test(normalizedRel);
417
+ }
418
+
419
+ function matchesAnyGlob(rel, patterns = []) {
420
+ return patterns.some((p) => matchesGlob(rel, p));
421
+ }
422
+
423
+ function skippedFileNode(relPath, reason) {
424
+ return {
425
+ file: relPath, lang: "js", parsed: false, parseErrors: 0,
426
+ functions: [], imports: [], exports: [], loc: 0, skippedReason: reason,
427
+ moduleType: null, varCount: 0, decoratorRoutes: [],
428
+ };
429
+ }
430
+
431
+ /**
432
+ * Walk a project directory and parse every supported file deterministically.
433
+ * Optional `cache` (Map rel → {hash, node}) skips re-parsing unchanged files —
434
+ * correctness identical (keyed on content hash, not mtime), cost proportional
435
+ * to the diff. Used by `mapd watch`.
436
+ */
437
+ export function parseProject(rootDir, { ignore = DEFAULT_IGNORE, cache = null, include = [], exclude = [], maxFileSizeBytes = Infinity, polyglot = true } = {}) {
438
+ const files = [];
439
+ const skipped = []; // supported-language files we failed to parse
440
+ const unsupported = []; // files in languages with no adapter yet (honestly reported)
441
+ let cacheHits = 0, parsedCount = 0;
442
+
443
+ const parseOne = (abs, rel, parseFn) => {
444
+ const stat = fs.statSync(abs);
445
+ if (Number.isFinite(maxFileSizeBytes) && stat.size > maxFileSizeBytes) {
446
+ const parsed = skippedFileNode(rel, "file-too-large");
447
+ cache?.delete(rel);
448
+ files.push(parsed);
449
+ skipped.push(rel);
450
+ return;
451
+ }
452
+ let parsed;
453
+ if (cache) {
454
+ const content = fs.readFileSync(abs);
455
+ const hash = crypto.createHash("sha1").update(content).digest("hex");
456
+ const hit = cache.get(rel);
457
+ if (hit && hit.hash === hash) {
458
+ parsed = hit.node;
459
+ cacheHits++;
460
+ } else {
461
+ parsed = parseFn(abs, rel);
462
+ parsedCount++;
463
+ cache.set(rel, { hash, node: parsed });
464
+ }
465
+ } else {
466
+ parsed = parseFn(abs, rel);
467
+ parsedCount++;
468
+ }
469
+ files.push(parsed);
470
+ if (!parsed.parsed) skipped.push(rel);
471
+ };
472
+
473
+ const walk = (dir) => {
474
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
475
+ if (entry.name.startsWith(".") && entry.name !== ".env.example") continue;
476
+ const abs = path.join(dir, entry.name);
477
+ const rel = normalizeRelPath(path.relative(rootDir, abs));
478
+ if (matchesAnyGlob(rel, exclude)) continue;
479
+ if (entry.isDirectory()) {
480
+ if (!ignore.has(entry.name)) walk(abs);
481
+ continue;
482
+ }
483
+ const ext = path.extname(entry.name).toLowerCase();
484
+ if (include.length && !matchesAnyGlob(rel, include)) continue;
485
+ if (JS_EXTENSIONS.has(ext)) {
486
+ parseOne(abs, rel, parseJsFile);
487
+ } else if (POLYGLOT_EXTENSIONS[ext]) {
488
+ // heuristic-tier adapter (see polyglot.js); the `mapping.polyglot: false`
489
+ // kill switch reverts these to the honest "unsupported" bucket
490
+ if (polyglot) parseOne(abs, rel, parsePolyglotFile);
491
+ else unsupported.push(rel);
492
+ } else if (KNOWN_UNSUPPORTED_EXTENSIONS.has(ext)) {
493
+ unsupported.push(rel);
494
+ }
495
+ }
496
+ };
497
+ walk(rootDir);
498
+ if (cache) {
499
+ const live = new Set(files.map((f) => f.file));
500
+ for (const key of cache.keys()) if (!live.has(key)) cache.delete(key);
501
+ }
502
+ return { files, skipped, unsupported, cacheHits, parsedCount };
503
+ }
504
+
505
+ /**
506
+ * Most recent mtime among source files parseProject would scan — same ignore
507
+ * rules, no parsing, just a stat walk. Used by staleness.js to tell whether
508
+ * an on-disk report predates a source change, without duplicating or
509
+ * drifting from parseProject's own file-discovery rules.
510
+ */
511
+ export function latestSourceMtime(rootDir, ignore = DEFAULT_IGNORE) {
512
+ let latest = 0;
513
+ const walk = (dir) => {
514
+ let entries;
515
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
516
+ for (const entry of entries) {
517
+ if (entry.name.startsWith(".") && entry.name !== ".env.example") continue;
518
+ const abs = path.join(dir, entry.name);
519
+ if (entry.isDirectory()) { if (!ignore.has(entry.name)) walk(abs); continue; }
520
+ const ext = path.extname(entry.name).toLowerCase();
521
+ if (!JS_EXTENSIONS.has(ext) && !POLYGLOT_EXTENSIONS[ext]) continue;
522
+ try { const m = fs.statSync(abs).mtimeMs; if (m > latest) latest = m; } catch { /* file vanished mid-walk */ }
523
+ }
524
+ };
525
+ walk(rootDir);
526
+ return latest;
527
+ }
528
+
529
+ /**
530
+ * ParserAdapter interface (for future languages):
531
+ * { extensions: Set<string>, parseFile(absPath, relPath) -> FileNode }
532
+ * Adapter #2 is polyglot.js: heuristic-tier extraction for Python/Go/Rust/
533
+ * Ruby/Java/PHP, every node marked `parserKind: "heuristic"` so downstream
534
+ * consumers can discount it. A full tree-sitter adapter would slot in the
535
+ * same way and simply drop the heuristic mark.
536
+ */
@@ -0,0 +1,65 @@
1
+ /**
2
+ * policy.js — the command-policy classifier shared by chat and mcp. Every
3
+ * dev command chat/mcp might run (npm test, git diff, ...) is classified
4
+ * before it is ever spawned; anything not in the allowlist table below is
5
+ * refused outright, regardless of classification.
6
+ */
7
+
8
+ export const CLASS = {
9
+ READ_ONLY: "read-only",
10
+ VERIFICATION: "verification",
11
+ PROJECT_MUTATION: "project-mutation",
12
+ DEPENDENCY_MUTATION: "dependency-mutation",
13
+ GIT_MUTATION: "git-mutation",
14
+ DESTRUCTIVE: "destructive",
15
+ NETWORKED: "networked",
16
+ };
17
+
18
+ /** Any of these may legitimately be "the project's package manager" — never assume it's npm. */
19
+ const PACKAGE_MANAGERS = new Set(["npm", "yarn", "pnpm", "bun"]);
20
+
21
+ /** Ordered rules — first match wins. `test` receives (cmd, args[]). */
22
+ const RULES = [
23
+ { test: (c, a) => PACKAGE_MANAGERS.has(c) && a[0] === "test", classification: CLASS.VERIFICATION },
24
+ { test: (c, a) => PACKAGE_MANAGERS.has(c) && a[0] === "run" && ["lint", "typecheck", "type-check", "build"].includes(a[1]), classification: CLASS.VERIFICATION },
25
+ { test: (c, a) => PACKAGE_MANAGERS.has(c) && a[0] === "run" && ["dev", "start"].includes(a[1]), classification: CLASS.NETWORKED },
26
+ { test: (c, a) => PACKAGE_MANAGERS.has(c) && a[0] === "start", classification: CLASS.NETWORKED },
27
+ { test: (c, a) => PACKAGE_MANAGERS.has(c) && ["install", "ci", "update", "uninstall", "add", "remove"].includes(a[0]), classification: CLASS.DEPENDENCY_MUTATION },
28
+
29
+ { test: (c, a) => c === "git" && ["status", "diff", "log", "show", "branch"].includes(a[0]), classification: CLASS.READ_ONLY },
30
+ { test: (c, a) => c === "git" && a[0] === "push" && a.includes("--force"), classification: CLASS.DESTRUCTIVE },
31
+ { test: (c, a) => c === "git" && ["push", "commit", "add", "checkout", "merge", "rebase"].includes(a[0]), classification: CLASS.GIT_MUTATION },
32
+ { test: (c, a) => c === "git" && ["reset", "clean"].includes(a[0]) && (a.includes("--hard") || a.includes("-f") || a.includes("-fd")), classification: CLASS.DESTRUCTIVE },
33
+
34
+ { test: (c) => ["grep", "rg", "find", "ls", "cat", "wc"].includes(c), classification: CLASS.READ_ONLY },
35
+ { test: (c, a) => c === "rm", classification: CLASS.DESTRUCTIVE },
36
+ ];
37
+
38
+ /** Returns `{ classification, allowed }` — `allowed: false` means "not in the allowlist, refuse outright." */
39
+ export function classifyCommand(cmd, args = []) {
40
+ const rule = RULES.find((r) => r.test(cmd, args));
41
+ if (!rule) return { classification: null, allowed: false, reason: `'${cmd}' is not an allowlisted command` };
42
+ return { classification: rule.classification, allowed: true };
43
+ }
44
+
45
+ /**
46
+ * Decide whether a classified command may run given the resolved config and
47
+ * whether the caller has already obtained explicit human approval.
48
+ */
49
+ export function isPermitted(classification, config, { approved = false } = {}) {
50
+ switch (classification) {
51
+ case CLASS.READ_ONLY:
52
+ case CLASS.VERIFICATION:
53
+ return { permitted: approved || config.chat?.autoRunReadOnly !== false, requiresApproval: false };
54
+ case CLASS.PROJECT_MUTATION:
55
+ case CLASS.DEPENDENCY_MUTATION:
56
+ case CLASS.GIT_MUTATION:
57
+ return { permitted: approved, requiresApproval: true };
58
+ case CLASS.NETWORKED:
59
+ return { permitted: approved && config.security?.allowNetworkCommands === true, requiresApproval: true };
60
+ case CLASS.DESTRUCTIVE:
61
+ return { permitted: approved && config.security?.allowDestructiveCommands === true, requiresApproval: true };
62
+ default:
63
+ return { permitted: false, requiresApproval: true };
64
+ }
65
+ }