@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,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config/index.js — .mapdrc loading, precedence, and validation.
|
|
3
|
+
*
|
|
4
|
+
* Precedence (later wins): DEFAULTS < user `~/.mapdrc` < project `.mapdrc`
|
|
5
|
+
* < `MAPD_*` env vars < explicit CLI overrides.
|
|
6
|
+
*
|
|
7
|
+
* Format: JSONC-lite — plain JSON with `//` and /* *\/ comments stripped,
|
|
8
|
+
* respecting string literals so a `//` inside a string value is never
|
|
9
|
+
* mistaken for a comment. No YAML dependency.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import { DEFAULTS, validateAgainstSchema, ANNOTATION_CLASSIFICATIONS } from "./schema.js";
|
|
16
|
+
import { applyRealTreeWrite } from "../core/changes.js";
|
|
17
|
+
|
|
18
|
+
/** Strip //-line and /* *\/-block comments from JSONC, respecting string literals. */
|
|
19
|
+
export function stripJsonComments(text) {
|
|
20
|
+
let out = "";
|
|
21
|
+
let inString = false;
|
|
22
|
+
let stringQuote = null;
|
|
23
|
+
for (let i = 0; i < text.length; i++) {
|
|
24
|
+
const c = text[i];
|
|
25
|
+
const next = text[i + 1];
|
|
26
|
+
|
|
27
|
+
if (inString) {
|
|
28
|
+
out += c;
|
|
29
|
+
if (c === "\\") { out += next ?? ""; i++; continue; }
|
|
30
|
+
if (c === stringQuote) inString = false;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (c === '"' || c === "'") { inString = true; stringQuote = c; out += c; continue; }
|
|
34
|
+
if (c === "/" && next === "/") { while (i < text.length && text[i] !== "\n") i++; out += "\n"; continue; }
|
|
35
|
+
if (c === "/" && next === "*") {
|
|
36
|
+
i += 2;
|
|
37
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
|
38
|
+
i++; // consume the closing '/'
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
out += c;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readMapdrc(filePath) {
|
|
47
|
+
if (!fs.existsSync(filePath)) return null;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(stripJsonComments(fs.readFileSync(filePath, "utf8")));
|
|
50
|
+
} catch (e) {
|
|
51
|
+
throw new Error(`Failed to parse ${filePath}: ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isPlainObject(v) {
|
|
56
|
+
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function deepMerge(base, override) {
|
|
60
|
+
if (!isPlainObject(base) || !isPlainObject(override)) return override ?? base;
|
|
61
|
+
const out = { ...base };
|
|
62
|
+
for (const [k, v] of Object.entries(override)) {
|
|
63
|
+
out[k] = isPlainObject(v) && isPlainObject(base[k]) ? deepMerge(base[k], v) : v;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setPath(obj, dotted, value) {
|
|
69
|
+
const parts = dotted.split(".");
|
|
70
|
+
let node = obj;
|
|
71
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
72
|
+
node[parts[i]] = isPlainObject(node[parts[i]]) ? node[parts[i]] : {};
|
|
73
|
+
node = node[parts[i]];
|
|
74
|
+
}
|
|
75
|
+
node[parts[parts.length - 1]] = value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Recognized MAPD_* env overrides, mapped onto config paths. Documented, not "magic generic". */
|
|
79
|
+
const ENV_MAP = {
|
|
80
|
+
MAPD_PROVIDER: { path: "chat.provider", parse: (v) => v },
|
|
81
|
+
MAPD_MODEL: { path: "chat.model", parse: (v) => v },
|
|
82
|
+
MAPD_FIX_MAX_ATTEMPTS: { path: "fix.maxAttempts", parse: (v) => parseInt(v, 10) },
|
|
83
|
+
MAPD_MCP_TRANSPORT: { path: "mcp.transport", parse: (v) => v },
|
|
84
|
+
MAPD_ALLOW_DESTRUCTIVE: { path: "security.allowDestructiveCommands", parse: (v) => v === "true" || v === "1" },
|
|
85
|
+
MAPD_ALLOW_NETWORK: { path: "security.allowNetworkCommands", parse: (v) => v === "true" || v === "1" },
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function envOverrides(env) {
|
|
89
|
+
const out = {};
|
|
90
|
+
for (const [key, { path: p, parse }] of Object.entries(ENV_MAP)) {
|
|
91
|
+
if (env[key] !== undefined) setPath(out, p, parse(env[key]));
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Load and merge configuration. Never throws for missing files; throws only
|
|
98
|
+
* on a malformed .mapdrc so the user gets an actionable parse error.
|
|
99
|
+
*/
|
|
100
|
+
export function loadConfig(rootDir, { cliOverrides = {}, env = process.env, homeDir = os.homedir() } = {}) {
|
|
101
|
+
const abs = path.resolve(rootDir);
|
|
102
|
+
const userRc = readMapdrc(path.join(homeDir, ".mapdrc")) ?? {};
|
|
103
|
+
const projectRc = readMapdrc(path.join(abs, ".mapdrc")) ?? {};
|
|
104
|
+
const envRc = envOverrides(env);
|
|
105
|
+
|
|
106
|
+
let merged = deepMerge(DEFAULTS, userRc);
|
|
107
|
+
merged = deepMerge(merged, projectRc);
|
|
108
|
+
merged = deepMerge(merged, envRc);
|
|
109
|
+
merged = deepMerge(merged, cliOverrides);
|
|
110
|
+
return merged;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Structural + schema validation. Never throws — returns {ok, errors}. */
|
|
114
|
+
export function validateConfig(config) {
|
|
115
|
+
const errors = [];
|
|
116
|
+
if (!isPlainObject(config)) return { ok: false, errors: ["config must be an object"] };
|
|
117
|
+
for (const section of ["project", "mapping", "chat", "fix", "mcp", "security", "providers"]) {
|
|
118
|
+
if (config[section] !== undefined && !isPlainObject(config[section])) {
|
|
119
|
+
errors.push(`${section}: must be an object`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
errors.push(...validateAgainstSchema(config));
|
|
123
|
+
return { ok: errors.length === 0, errors };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function mapdrcPath(rootDir) {
|
|
127
|
+
return path.join(path.resolve(rootDir), ".mapdrc");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Annotation memory (`mapd annotate ...`) — user-asserted classifications for
|
|
132
|
+
* things static analysis structurally cannot know, persisted in the PROJECT
|
|
133
|
+
* .mapdrc under project.annotations so every surface (cli/chat/mcp/handoff/
|
|
134
|
+
* solutions) sees them through the normal config merge. Editing rewrites the
|
|
135
|
+
* file as pretty JSON; if the existing .mapdrc had JSONC comments they are
|
|
136
|
+
* not preserved, and `hadComments` is returned so callers can say so instead
|
|
137
|
+
* of silently eating documentation. The write goes through
|
|
138
|
+
* applyRealTreeWrite — .mapdrc is a real project file, so the edit is
|
|
139
|
+
* recorded as a rollback-able change like every other real-tree mutation.
|
|
140
|
+
*/
|
|
141
|
+
export function setAnnotation(rootDir, pattern, classification) {
|
|
142
|
+
if (!ANNOTATION_CLASSIFICATIONS.includes(classification)) {
|
|
143
|
+
return { ok: false, reason: `classification must be one of ${ANNOTATION_CLASSIFICATIONS.map((c) => `"${c}"`).join(", ")} (got "${classification}")` };
|
|
144
|
+
}
|
|
145
|
+
const p = mapdrcPath(rootDir);
|
|
146
|
+
const raw = fs.existsSync(p) ? fs.readFileSync(p, "utf8") : null;
|
|
147
|
+
let rc;
|
|
148
|
+
try {
|
|
149
|
+
rc = raw === null ? {} : JSON.parse(stripJsonComments(raw));
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { ok: false, reason: `cannot edit ${p}: ${e.message}` };
|
|
152
|
+
}
|
|
153
|
+
rc.project = isPlainObject(rc.project) ? rc.project : {};
|
|
154
|
+
rc.project.annotations = isPlainObject(rc.project.annotations) ? rc.project.annotations : {};
|
|
155
|
+
const replaced = Object.prototype.hasOwnProperty.call(rc.project.annotations, pattern);
|
|
156
|
+
rc.project.annotations[pattern] = classification;
|
|
157
|
+
const change = applyRealTreeWrite(rootDir, ".mapdrc", JSON.stringify(rc, null, 2) + "\n", "annotate");
|
|
158
|
+
return { ok: true, path: p, pattern, classification, replaced, changeId: change.id, hadComments: raw !== null && stripJsonComments(raw) !== raw };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function removeAnnotation(rootDir, pattern) {
|
|
162
|
+
const p = mapdrcPath(rootDir);
|
|
163
|
+
if (!fs.existsSync(p)) return { ok: false, reason: `no .mapdrc at ${p}` };
|
|
164
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
165
|
+
let rc;
|
|
166
|
+
try {
|
|
167
|
+
rc = JSON.parse(stripJsonComments(raw));
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return { ok: false, reason: `cannot edit ${p}: ${e.message}` };
|
|
170
|
+
}
|
|
171
|
+
if (!isPlainObject(rc.project?.annotations) || !Object.prototype.hasOwnProperty.call(rc.project.annotations, pattern)) {
|
|
172
|
+
return { ok: false, reason: `no annotation for pattern "${pattern}" in ${p}` };
|
|
173
|
+
}
|
|
174
|
+
delete rc.project.annotations[pattern];
|
|
175
|
+
const change = applyRealTreeWrite(rootDir, ".mapdrc", JSON.stringify(rc, null, 2) + "\n", "annotate");
|
|
176
|
+
return { ok: true, path: p, pattern, changeId: change.id, hadComments: stripJsonComments(raw) !== raw };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The fully-resolved annotation map (defaults + user + project + env), for listing. */
|
|
180
|
+
export function listAnnotations(rootDir) {
|
|
181
|
+
const resolved = loadConfig(rootDir);
|
|
182
|
+
return resolved.project?.annotations ?? {};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** `mapd config init` — writes a commented starter .mapdrc from DEFAULTS. Never overwrites silently. */
|
|
186
|
+
export function initConfig(rootDir, { force = false } = {}) {
|
|
187
|
+
const p = mapdrcPath(rootDir);
|
|
188
|
+
if (fs.existsSync(p) && !force) {
|
|
189
|
+
return { ok: false, path: p, reason: "already exists (use --force to overwrite)" };
|
|
190
|
+
}
|
|
191
|
+
const body =
|
|
192
|
+
"// Map'd project configuration (JSONC — // and /* */ comments are stripped before parsing).\n" +
|
|
193
|
+
"// See README.md \"Configuration\" for the full field reference.\n" +
|
|
194
|
+
JSON.stringify(DEFAULTS, null, 2) + "\n";
|
|
195
|
+
fs.writeFileSync(p, body);
|
|
196
|
+
return { ok: true, path: p };
|
|
197
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config/schema.js — the .mapdrc shape and defaults.
|
|
3
|
+
*
|
|
4
|
+
* Format: JSONC-lite. Plain JSON with `//` and `/* *\/` comments stripped
|
|
5
|
+
* before JSON.parse (see index.js). Chosen over YAML to avoid adding a new
|
|
6
|
+
* dependency; every field below maps 1:1 onto the project's documented
|
|
7
|
+
* configuration surface.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const DEFAULTS = Object.freeze({
|
|
11
|
+
project: {
|
|
12
|
+
root: ".",
|
|
13
|
+
include: [],
|
|
14
|
+
exclude: ["node_modules/**", "dist/**", "build/**", "coverage/**", "eval/results/**", ".next/**", ".git/**"],
|
|
15
|
+
// User-asserted classifications for things static analysis structurally
|
|
16
|
+
// cannot know: glob pattern -> "generated" | "dynamically-loaded".
|
|
17
|
+
// e.g. { "eval/results/**": "generated", "electron/tools/**": "dynamically-loaded" }
|
|
18
|
+
// Output always labels these as user-asserted, never as detected.
|
|
19
|
+
annotations: {},
|
|
20
|
+
},
|
|
21
|
+
mapping: {
|
|
22
|
+
confidenceThreshold: 0.8,
|
|
23
|
+
orphanThreshold: 0.5,
|
|
24
|
+
maxFileSizeBytes: 1_000_000,
|
|
25
|
+
cache: true,
|
|
26
|
+
// Heuristic mapping of non-JS/TS languages (Python/Go/Rust/Ruby/Java/PHP)
|
|
27
|
+
// — see core/polyglot.js. Set false to tell Map'd to stop mapping those
|
|
28
|
+
// languages entirely; they revert to the honest "unsupported" bucket.
|
|
29
|
+
polyglot: true,
|
|
30
|
+
},
|
|
31
|
+
chat: {
|
|
32
|
+
provider: "auto",
|
|
33
|
+
model: "auto",
|
|
34
|
+
autoRunReadOnly: true,
|
|
35
|
+
requireApprovalForWrites: true,
|
|
36
|
+
allowShell: false,
|
|
37
|
+
maxContextTokens: 30_000,
|
|
38
|
+
maxCommandOutputCharacters: 30_000,
|
|
39
|
+
},
|
|
40
|
+
fix: {
|
|
41
|
+
maxAttempts: 2,
|
|
42
|
+
requireApproval: true,
|
|
43
|
+
allowedPaths: [],
|
|
44
|
+
forbiddenPaths: [".env", ".env.*", "secrets/**"],
|
|
45
|
+
runTests: true,
|
|
46
|
+
runTypecheck: true,
|
|
47
|
+
runLint: true,
|
|
48
|
+
},
|
|
49
|
+
mcp: {
|
|
50
|
+
enabled: true,
|
|
51
|
+
transport: "stdio",
|
|
52
|
+
},
|
|
53
|
+
security: {
|
|
54
|
+
allowNetworkCommands: false,
|
|
55
|
+
allowDestructiveCommands: false,
|
|
56
|
+
allowedCommands: [],
|
|
57
|
+
deniedCommands: [],
|
|
58
|
+
},
|
|
59
|
+
providers: {
|
|
60
|
+
anthropic: { model: "" },
|
|
61
|
+
openai: { model: "" },
|
|
62
|
+
kimi: { model: "" },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The only annotation classifications Map'd accepts — shared by the schema
|
|
68
|
+
* validator and `mapd annotate`. These are the "tell Map'd" levers for what
|
|
69
|
+
* static analysis structurally cannot know:
|
|
70
|
+
* generated — build/tool output; quarantined from scans
|
|
71
|
+
* dynamically-loaded — loaded at runtime by a pattern no detector sees
|
|
72
|
+
* entrypoint — a real entry point (any language); grows a workflow
|
|
73
|
+
* intentional-dormant — deliberately kept code; never reported as an orphan
|
|
74
|
+
*/
|
|
75
|
+
export const ANNOTATION_CLASSIFICATIONS = Object.freeze(["generated", "dynamically-loaded", "entrypoint", "intentional-dormant"]);
|
|
76
|
+
|
|
77
|
+
/** Field-level validators. Each returns an error string, or null if valid. */
|
|
78
|
+
const VALIDATORS = {
|
|
79
|
+
"project.include": (v) => (Array.isArray(v) && v.every((x) => typeof x === "string") ? null : "must be an array of glob strings"),
|
|
80
|
+
"project.exclude": (v) => (Array.isArray(v) && v.every((x) => typeof x === "string") ? null : "must be an array of glob strings"),
|
|
81
|
+
"mapping.confidenceThreshold": (v) => (typeof v === "number" && v >= 0 && v <= 1 ? null : "must be a number between 0 and 1"),
|
|
82
|
+
"mapping.orphanThreshold": (v) => (typeof v === "number" && v >= 0 && v <= 1 ? null : "must be a number between 0 and 1"),
|
|
83
|
+
"mapping.maxFileSizeBytes": (v) => (typeof v === "number" && v > 0 ? null : "must be a positive number"),
|
|
84
|
+
"chat.maxContextTokens": (v) => (typeof v === "number" && v > 0 ? null : "must be a positive number"),
|
|
85
|
+
"chat.maxCommandOutputCharacters": (v) => (typeof v === "number" && v > 0 ? null : "must be a positive number"),
|
|
86
|
+
"fix.maxAttempts": (v) => (Number.isInteger(v) && v >= 1 ? null : "must be an integer >= 1"),
|
|
87
|
+
"mapping.polyglot": (v) => (typeof v === "boolean" ? null : "must be a boolean"),
|
|
88
|
+
"mcp.transport": (v) => (v === "stdio" ? null : "only 'stdio' transport is currently supported"),
|
|
89
|
+
"project.annotations": (v) => {
|
|
90
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) return "must be an object mapping glob patterns to classifications";
|
|
91
|
+
const allowed = new Set(ANNOTATION_CLASSIFICATIONS);
|
|
92
|
+
for (const [pattern, raw] of Object.entries(v)) {
|
|
93
|
+
// an annotation value may be a bare classification string, or an object
|
|
94
|
+
// { classification, reason?, source?, date? } carrying attribution
|
|
95
|
+
// (see `mapd config lint` — attribution is optional but recommended).
|
|
96
|
+
const cls = typeof raw === "string" ? raw : (raw && typeof raw === "object" && !Array.isArray(raw) ? raw.classification : undefined);
|
|
97
|
+
if (typeof cls !== "string" || !allowed.has(cls)) {
|
|
98
|
+
return `"${pattern}": classification must be one of ${[...allowed].map((a) => `"${a}"`).join(", ")} (got "${typeof raw === "object" ? JSON.stringify(raw) : raw}")`;
|
|
99
|
+
}
|
|
100
|
+
if (raw && typeof raw === "object") {
|
|
101
|
+
for (const k of Object.keys(raw)) {
|
|
102
|
+
if (!["classification", "reason", "source", "date"].includes(k)) return `"${pattern}": unknown attribution field "${k}" (allowed: classification, reason, source, date)`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export function validateAgainstSchema(config) {
|
|
111
|
+
const errors = [];
|
|
112
|
+
for (const [pathKey, validate] of Object.entries(VALIDATORS)) {
|
|
113
|
+
const value = pathKey.split(".").reduce((o, k) => o?.[k], config);
|
|
114
|
+
if (value === undefined) continue;
|
|
115
|
+
const err = validate(value);
|
|
116
|
+
if (err) errors.push(`${pathKey}: ${err}`);
|
|
117
|
+
}
|
|
118
|
+
return errors;
|
|
119
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assist.js — bare `mapd` (no subcommand) guidance. Inspects real project
|
|
3
|
+
* state (config presence, baseline presence, open findings) and returns the
|
|
4
|
+
* one or two commands that actually move things forward next — never a
|
|
5
|
+
* generic help dump. Every claim here is read from the same deterministic
|
|
6
|
+
* sources the rest of Map'd uses (buildScoredGraph, loadBaseline, the review
|
|
7
|
+
* queue) — nothing here is guessed or narrated by an LLM.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { buildScoredGraph } from "./intelligence.js";
|
|
13
|
+
import { loadBaseline } from "./regression.js";
|
|
14
|
+
import { loadQueueWithStates, pending } from "./review.js";
|
|
15
|
+
|
|
16
|
+
export function buildAssist(abs) {
|
|
17
|
+
const hasConfig = fs.existsSync(path.join(abs, ".mapdrc"));
|
|
18
|
+
const g = buildScoredGraph(abs);
|
|
19
|
+
const baseline = loadBaseline(abs);
|
|
20
|
+
const { items } = loadQueueWithStates(abs);
|
|
21
|
+
const open = pending(items);
|
|
22
|
+
const highSeverity = open.filter((i) => i.severity === "high");
|
|
23
|
+
|
|
24
|
+
const steps = [];
|
|
25
|
+
if (!hasConfig) {
|
|
26
|
+
steps.push({ cmd: "mapd config init", why: "No .mapdrc yet — write starter config with documented defaults." });
|
|
27
|
+
}
|
|
28
|
+
if (!baseline) {
|
|
29
|
+
steps.push({ cmd: "mapd check --save-baseline", why: "No regression baseline yet — snapshot the current map so future changes can be diffed." });
|
|
30
|
+
} else if (baseline.schemaMismatch) {
|
|
31
|
+
steps.push({ cmd: "mapd check --save-baseline", why: `Baseline schema v${baseline.schemaMismatch.found} predates this Map'd (v${baseline.schemaMismatch.expected}) — re-snapshot.` });
|
|
32
|
+
}
|
|
33
|
+
if (highSeverity.length) {
|
|
34
|
+
steps.push({ cmd: "mapd fix --propose", why: `${highSeverity.length} high-severity finding(s) open — auto-selects and proposes a gate-verified fix for the strongest one.` });
|
|
35
|
+
} else if (open.length) {
|
|
36
|
+
steps.push({ cmd: "mapd fix review", why: `${open.length} item(s) awaiting approval.` });
|
|
37
|
+
}
|
|
38
|
+
if (baseline && !baseline.schemaMismatch) {
|
|
39
|
+
steps.push({ cmd: "mapd check", why: "Re-check for regressions against the saved baseline." });
|
|
40
|
+
}
|
|
41
|
+
steps.push({ cmd: "mapd chat", why: "Ask questions, get a ranked \"what should I work on\" plan, or explore the score breakdown." });
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
root: abs,
|
|
45
|
+
summary: { fileCount: g.stats.fileCount, workflowCount: g.workflows.length, repoConfidence: g.repoConfidence },
|
|
46
|
+
openFindings: open.length,
|
|
47
|
+
steps: steps.slice(0, 4),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function renderAssist(data, { bold, dim, cyan, confidenceColor }) {
|
|
52
|
+
const lines = [];
|
|
53
|
+
lines.push(`${bold("Map'd")} — ${dim(data.root)}`);
|
|
54
|
+
lines.push(` files: ${data.summary.fileCount} workflows: ${data.summary.workflowCount} repo confidence: ${confidenceColor(data.summary.repoConfidence)(data.summary.repoConfidence)} open findings: ${data.openFindings}`);
|
|
55
|
+
lines.push("");
|
|
56
|
+
lines.push(bold("What to run next:"));
|
|
57
|
+
for (const s of data.steps) {
|
|
58
|
+
lines.push(` ${cyan(s.cmd)}`);
|
|
59
|
+
lines.push(` ${dim(s.why)}`);
|
|
60
|
+
}
|
|
61
|
+
lines.push("");
|
|
62
|
+
lines.push(dim("mapd --help lists every command; mapd chat answers plain-English questions about this project."));
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audit.js — structured, append-only audit records for every command that
|
|
3
|
+
* touches the project (fix attempts, integrate applies, chat mutations, mcp
|
|
4
|
+
* tool calls). Human-readable via `mapd audit <id>`, machine-readable via
|
|
5
|
+
* `--json`. Secrets are redacted before anything is written.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { redactSecrets } from "./security.js";
|
|
12
|
+
|
|
13
|
+
const MAPD = ".mapd";
|
|
14
|
+
const SCHEMA = 1;
|
|
15
|
+
|
|
16
|
+
function auditsDir(rootDir) { return path.join(path.resolve(rootDir), MAPD, "audits"); }
|
|
17
|
+
|
|
18
|
+
function redactDeep(value) {
|
|
19
|
+
if (typeof value === "string") return redactSecrets(value);
|
|
20
|
+
if (Array.isArray(value)) return value.map(redactDeep);
|
|
21
|
+
if (value && typeof value === "object") {
|
|
22
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactDeep(v)]));
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `record` should include: command, initiator, sessionId?, changeId?,
|
|
29
|
+
* provider?, model?, contextSources?, filesInspected?, filesChanged?,
|
|
30
|
+
* approvalStatus?, attempt?, gates?, commandsRun?, exitCodes?, durationMs?,
|
|
31
|
+
* tokenUsage?, finalStatus, rollbackStatus?.
|
|
32
|
+
*/
|
|
33
|
+
export function recordAudit(rootDir, record) {
|
|
34
|
+
const dir = auditsDir(rootDir);
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
const id = crypto.randomUUID();
|
|
37
|
+
const full = redactDeep({
|
|
38
|
+
mapdSchema: SCHEMA,
|
|
39
|
+
id,
|
|
40
|
+
timestamp: new Date().toISOString(),
|
|
41
|
+
...record,
|
|
42
|
+
});
|
|
43
|
+
const file = path.join(dir, `${full.timestamp.replace(/[:.]/g, "-")}-${id.slice(0, 8)}.json`);
|
|
44
|
+
fs.writeFileSync(file, JSON.stringify(full, null, 2));
|
|
45
|
+
return full;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadAudits(rootDir) {
|
|
49
|
+
const dir = auditsDir(rootDir);
|
|
50
|
+
if (!fs.existsSync(dir)) return [];
|
|
51
|
+
return fs.readdirSync(dir)
|
|
52
|
+
.filter((n) => n.endsWith(".json"))
|
|
53
|
+
.map((n) => {
|
|
54
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, n), "utf8")); }
|
|
55
|
+
catch { return null; }
|
|
56
|
+
})
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getAudit(rootDir, id) {
|
|
62
|
+
return loadAudits(rootDir).find((a) => a.id === id || a.id.startsWith(id)) ?? null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* changes.js — the single funnel for every real-tree mutation Map'd makes
|
|
3
|
+
* (fix apply, integrate apply, review approve). Every write through here is
|
|
4
|
+
* also recorded as a stable, rollback-able change record — approval state
|
|
5
|
+
* is never allowed to stand in for an actual recorded, reversible write.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { sanitizeRelPath, isProtectedPath } from "./security.js";
|
|
12
|
+
|
|
13
|
+
const MAPD = ".mapd";
|
|
14
|
+
|
|
15
|
+
function changesDir(rootDir) { return path.join(rootDir, MAPD, "changes"); }
|
|
16
|
+
function blobsDir(rootDir) { return path.join(changesDir(rootDir), "blobs"); }
|
|
17
|
+
|
|
18
|
+
function newChangeId() {
|
|
19
|
+
return `chg-${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function hashOf(content) {
|
|
23
|
+
return content == null ? null : crypto.createHash("sha256").update(content).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readJson(p) {
|
|
27
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Write `newSource` to `file` (relative to rootDir) and record a change entry
|
|
32
|
+
* with a full before/after blob backup. Refuses protected paths and path
|
|
33
|
+
* traversal outright — this is the last line of defense before a real-tree
|
|
34
|
+
* write, independent of whatever upstream gates already ran.
|
|
35
|
+
*
|
|
36
|
+
* `source` identifies the origin ("fix" | "integrate" | "review") for the audit trail.
|
|
37
|
+
*/
|
|
38
|
+
export function applyRealTreeWrite(rootDir, file, newSource, source, { forbiddenPaths = [] } = {}) {
|
|
39
|
+
const abs = path.resolve(rootDir);
|
|
40
|
+
if (isProtectedPath(file, forbiddenPaths)) {
|
|
41
|
+
throw new Error(`Refusing to write a protected path: ${file}`);
|
|
42
|
+
}
|
|
43
|
+
const target = sanitizeRelPath(abs, file);
|
|
44
|
+
|
|
45
|
+
let before = null;
|
|
46
|
+
try { before = fs.readFileSync(target, "utf8"); } catch { /* new file */ }
|
|
47
|
+
|
|
48
|
+
const id = newChangeId();
|
|
49
|
+
fs.mkdirSync(blobsDir(abs), { recursive: true });
|
|
50
|
+
const beforePath = before != null ? path.join(blobsDir(abs), `${id}.before`) : null;
|
|
51
|
+
const afterPath = path.join(blobsDir(abs), `${id}.after`);
|
|
52
|
+
if (beforePath) fs.writeFileSync(beforePath, before);
|
|
53
|
+
fs.writeFileSync(afterPath, newSource);
|
|
54
|
+
|
|
55
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
56
|
+
fs.writeFileSync(target, newSource);
|
|
57
|
+
|
|
58
|
+
const record = {
|
|
59
|
+
mapdSchema: 1,
|
|
60
|
+
id,
|
|
61
|
+
at: new Date().toISOString(),
|
|
62
|
+
file,
|
|
63
|
+
source,
|
|
64
|
+
beforeHash: hashOf(before),
|
|
65
|
+
afterHash: hashOf(newSource),
|
|
66
|
+
beforePath, afterPath,
|
|
67
|
+
rolledBack: false,
|
|
68
|
+
};
|
|
69
|
+
fs.writeFileSync(path.join(changesDir(abs), `${id}.json`), JSON.stringify(record, null, 2));
|
|
70
|
+
return record;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function loadChanges(rootDir) {
|
|
74
|
+
const dir = changesDir(path.resolve(rootDir));
|
|
75
|
+
if (!fs.existsSync(dir)) return [];
|
|
76
|
+
return fs.readdirSync(dir)
|
|
77
|
+
.filter((n) => n.endsWith(".json"))
|
|
78
|
+
.map((n) => readJson(path.join(dir, n)))
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.sort((a, b) => a.at.localeCompare(b.at));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getChange(rootDir, changeId) {
|
|
84
|
+
return loadChanges(rootDir).find((c) => c.id === changeId) ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Restore the file this change touched to its pre-change content (or delete
|
|
89
|
+
* it, if the change created a new file). Append-only: the original record is
|
|
90
|
+
* never deleted, only marked rolledBack, and a new rollback record is appended.
|
|
91
|
+
*/
|
|
92
|
+
export function rollbackChange(rootDir, changeId) {
|
|
93
|
+
const abs = path.resolve(rootDir);
|
|
94
|
+
const record = getChange(abs, changeId);
|
|
95
|
+
if (!record) return { ok: false, detail: `no change with id ${changeId}` };
|
|
96
|
+
if (record.rolledBack) return { ok: false, detail: `change ${changeId} was already rolled back` };
|
|
97
|
+
|
|
98
|
+
const target = sanitizeRelPath(abs, record.file);
|
|
99
|
+
if (record.beforePath && fs.existsSync(record.beforePath)) {
|
|
100
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
101
|
+
fs.writeFileSync(target, fs.readFileSync(record.beforePath));
|
|
102
|
+
} else {
|
|
103
|
+
try { fs.rmSync(target, { force: true }); } catch { /* best effort */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
record.rolledBack = true;
|
|
107
|
+
record.rolledBackAt = new Date().toISOString();
|
|
108
|
+
fs.writeFileSync(path.join(changesDir(abs), `${changeId}.json`), JSON.stringify(record, null, 2));
|
|
109
|
+
return { ok: true, detail: `${record.file} restored to its state before change ${changeId}` };
|
|
110
|
+
}
|
|
Binary file
|