@deeeed/metamask-harness 0.51.5 → 0.51.7

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.
@@ -0,0 +1,513 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { detectAdapter } from "../harness.js";
6
+ import { usageError } from "../commands/parse-args.js";
7
+ const REVIEW_ADAPTERS = ["mobile", "extension", "core"];
8
+ const KNOWN_LIBRARIES = {
9
+ perps: { url: "https://github.com/MetaMask/experimental-metamask-recipe-perps.git" },
10
+ "money-movement": { url: "https://github.com/Consensys/money-movement-recipe-library.git" }
11
+ };
12
+ const SKILLS_CACHE_DIR = ".skills-cache";
13
+ const REFERENCE_ENV_PREFIX = "MM_HARNESS_REF_";
14
+ function referenceEnvName(adapter) {
15
+ return `${REFERENCE_ENV_PREFIX}${adapter.toUpperCase()}`;
16
+ }
17
+ function configPath(env = process.env, homeDir = os.homedir()) {
18
+ return env.MM_HARNESS_CONFIG ? path.resolve(env.MM_HARNESS_CONFIG) : path.join(homeDir, ".mm-harness", "config.json");
19
+ }
20
+ function readConfig(file) {
21
+ if (!fs.existsSync(file)) return {};
22
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
23
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
24
+ throw new Error(`mm-harness config must be a JSON object: ${file}`);
25
+ }
26
+ const record = parsed;
27
+ const config = {};
28
+ if (record.libraries !== void 0) config.libraries = stringMap(record.libraries, `${file}: libraries`);
29
+ if (record.references !== void 0) {
30
+ const references = stringMap(record.references, `${file}: references`);
31
+ for (const key of Object.keys(references)) {
32
+ if (!REVIEW_ADAPTERS.includes(key)) {
33
+ throw new Error(`${file}: references.${key} is not one of ${REVIEW_ADAPTERS.join(", ")}`);
34
+ }
35
+ }
36
+ config.references = references;
37
+ }
38
+ return config;
39
+ }
40
+ function stringMap(value, label) {
41
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
42
+ const out = {};
43
+ for (const [key, entry] of Object.entries(value)) {
44
+ if (typeof entry !== "string" || entry.trim() === "") throw new Error(`${label}.${key} must be a non-empty string`);
45
+ out[key] = entry;
46
+ }
47
+ return out;
48
+ }
49
+ function writeConfig(file, config) {
50
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 448 });
51
+ fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}
52
+ `, { mode: 384 });
53
+ }
54
+ function parseLibraryPathEnv(value) {
55
+ if (!value) return [];
56
+ return value.split(":").map((entry) => entry.trim()).filter(Boolean).map((entry) => {
57
+ const equals = entry.indexOf("=");
58
+ if (equals === -1) return { root: path.resolve(entry) };
59
+ return { name: entry.slice(0, equals).trim(), root: path.resolve(entry.slice(equals + 1).trim()) };
60
+ });
61
+ }
62
+ function isDir(candidate) {
63
+ try {
64
+ return fs.statSync(candidate).isDirectory();
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+ function looksLikeLibrary(root) {
70
+ return isDir(root) && (fs.existsSync(path.join(root, "owned-paths.json")) || isDir(path.join(root, "review")));
71
+ }
72
+ function gitRevision(root) {
73
+ try {
74
+ return execFileSync("git", ["-C", root, "rev-parse", "--short", "HEAD"], {
75
+ encoding: "utf8",
76
+ stdio: ["ignore", "pipe", "ignore"]
77
+ }).trim();
78
+ } catch {
79
+ return void 0;
80
+ }
81
+ }
82
+ function withRevision(location) {
83
+ const revision = gitRevision(location.root);
84
+ return revision ? { ...location, revision } : location;
85
+ }
86
+ function siblingRoots(context) {
87
+ const home = context.homeDir ?? os.homedir();
88
+ const parents = [path.dirname(path.resolve(context.target)), path.join(home, "shared-library")];
89
+ const roots = [];
90
+ for (const parent of parents) {
91
+ if (!isDir(parent)) continue;
92
+ for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
93
+ if (!entry.isDirectory()) continue;
94
+ roots.push(path.join(parent, entry.name));
95
+ }
96
+ }
97
+ return roots;
98
+ }
99
+ function siblingLibrary(name, context) {
100
+ const candidates = siblingRoots(context).filter((root) => {
101
+ const base = path.basename(root);
102
+ return base === name || base.endsWith(`recipe-${name}`) || base === `${name}-recipe-library`;
103
+ }).filter(looksLikeLibrary).sort((a, b) => path.basename(a).length - path.basename(b).length);
104
+ return candidates[0];
105
+ }
106
+ function libraryCachePath(target, name) {
107
+ return path.join(path.resolve(target), SKILLS_CACHE_DIR, name);
108
+ }
109
+ function gitExcludeFile(target) {
110
+ try {
111
+ const gitPath = execFileSync("git", ["-C", target, "rev-parse", "--git-path", "info/exclude"], {
112
+ encoding: "utf8",
113
+ stdio: ["ignore", "pipe", "ignore"]
114
+ }).trim();
115
+ return path.resolve(target, gitPath);
116
+ } catch {
117
+ return void 0;
118
+ }
119
+ }
120
+ function excludeCacheDir(target) {
121
+ const excludeFile = gitExcludeFile(target);
122
+ if (!excludeFile) return;
123
+ fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
124
+ const line = `${SKILLS_CACHE_DIR}/`;
125
+ const current = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, "utf8") : "";
126
+ if (current.split("\n").some((entry) => entry.trim() === line)) return;
127
+ fs.appendFileSync(excludeFile, `${current.endsWith("\n") || current === "" ? "" : "\n"}${line}
128
+ `);
129
+ }
130
+ function fetchLibrary(name, target) {
131
+ const known = KNOWN_LIBRARIES[name];
132
+ if (!known) {
133
+ throw usageError(
134
+ `No location for library "${name}" and it is not a known library (${Object.keys(KNOWN_LIBRARIES).join(", ")}). Set RECIPE_LIBRARY_PATH="${name}=<path>" or run: mm-harness config set libraries.${name} <path>`
135
+ );
136
+ }
137
+ const root = libraryCachePath(target, name);
138
+ fs.mkdirSync(path.dirname(root), { recursive: true });
139
+ execFileSync("git", ["clone", "--quiet", "--depth", "1", known.url, root], { stdio: ["ignore", "pipe", "inherit"] });
140
+ excludeCacheDir(path.resolve(target));
141
+ return withRevision({ name, root, source: "cache" });
142
+ }
143
+ function resolveLibrary(name, options) {
144
+ const env = options.env ?? process.env;
145
+ const fromEnv = parseLibraryPathEnv(env.RECIPE_LIBRARY_PATH).find(
146
+ (entry) => isDir(entry.root) && (entry.name === name || entry.name === void 0 && loadOwnedPaths(entry.root)?.domain === name)
147
+ );
148
+ if (fromEnv) return withRevision({ name, root: fromEnv.root, source: "env" });
149
+ const fromConfig = options.config?.libraries?.[name];
150
+ if (fromConfig && isDir(path.resolve(fromConfig))) {
151
+ return withRevision({ name, root: path.resolve(fromConfig), source: "config" });
152
+ }
153
+ const sibling = siblingLibrary(name, options);
154
+ if (sibling) return withRevision({ name, root: sibling, source: "sibling" });
155
+ const cached = libraryCachePath(options.target, name);
156
+ if (isDir(cached)) return withRevision({ name, root: cached, source: "cache" });
157
+ if (options.fetch) return fetchLibrary(name, options.target);
158
+ return void 0;
159
+ }
160
+ function discoverLibraries(context) {
161
+ const env = context.env ?? process.env;
162
+ const seenRoots = /* @__PURE__ */ new Set();
163
+ const seenNames = /* @__PURE__ */ new Set();
164
+ const found = [];
165
+ const add = (name, root, source) => {
166
+ const resolved = path.resolve(root);
167
+ if (seenRoots.has(resolved) || !looksLikeLibrary(resolved)) return;
168
+ const libraryName = name ?? loadOwnedPaths(resolved)?.domain ?? path.basename(resolved);
169
+ if (seenNames.has(libraryName)) return;
170
+ seenRoots.add(resolved);
171
+ seenNames.add(libraryName);
172
+ found.push(withRevision({ name: libraryName, root: resolved, source }));
173
+ };
174
+ for (const entry of parseLibraryPathEnv(env.RECIPE_LIBRARY_PATH)) add(entry.name, entry.root, "env");
175
+ for (const [name, root] of Object.entries(context.config?.libraries ?? {})) add(name, root, "config");
176
+ const siblingNames = /* @__PURE__ */ new Set();
177
+ for (const root of siblingRoots(context)) {
178
+ if (!looksLikeLibrary(root)) continue;
179
+ siblingNames.add(loadOwnedPaths(root)?.domain ?? path.basename(root));
180
+ }
181
+ for (const name of siblingNames) {
182
+ const chosen = siblingLibrary(name, context);
183
+ if (chosen) add(name, chosen, "sibling");
184
+ }
185
+ const cacheRoot = path.join(path.resolve(context.target), SKILLS_CACHE_DIR);
186
+ if (isDir(cacheRoot)) {
187
+ for (const entry of fs.readdirSync(cacheRoot)) add(entry, path.join(cacheRoot, entry), "cache");
188
+ }
189
+ return found;
190
+ }
191
+ function resolveReference(adapter, context) {
192
+ const env = context.env ?? process.env;
193
+ const fromEnv = env[referenceEnvName(adapter)];
194
+ if (fromEnv && isDir(path.resolve(fromEnv))) return { adapter, root: path.resolve(fromEnv), source: "env" };
195
+ const fromConfig = context.config?.references?.[adapter];
196
+ if (fromConfig && isDir(path.resolve(fromConfig))) return { adapter, root: path.resolve(fromConfig), source: "config" };
197
+ const target = path.resolve(context.target);
198
+ const siblings = siblingRoots(context).filter((root) => root !== target && detectAdapter(root) === adapter).sort((a, b) => {
199
+ const refA = path.basename(a).endsWith("-ref") ? 0 : 1;
200
+ const refB = path.basename(b).endsWith("-ref") ? 0 : 1;
201
+ return refA - refB || path.basename(a).length - path.basename(b).length || a.localeCompare(b);
202
+ });
203
+ if (siblings[0]) return { adapter, root: siblings[0], source: "sibling" };
204
+ return void 0;
205
+ }
206
+ function referenceHint(adapter) {
207
+ return `set ${referenceEnvName(adapter)} or run: mm-harness config set references.${adapter} <path>`;
208
+ }
209
+ function loadOwnedPaths(root) {
210
+ const file = path.join(root, "owned-paths.json");
211
+ if (!fs.existsSync(file)) return void 0;
212
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
213
+ if (!parsed || typeof parsed !== "object") throw new Error(`owned-paths.json must be an object: ${file}`);
214
+ const record = parsed;
215
+ if (typeof record.domain !== "string" || !record.domain) throw new Error(`owned-paths.json needs a "domain": ${file}`);
216
+ if (!record.repos || typeof record.repos !== "object") throw new Error(`owned-paths.json needs "repos": ${file}`);
217
+ const repos = {};
218
+ for (const [repo, globs] of Object.entries(record.repos)) {
219
+ if (!Array.isArray(globs) || globs.some((glob) => typeof glob !== "string")) {
220
+ throw new Error(`owned-paths.json repos.${repo} must be a string array: ${file}`);
221
+ }
222
+ repos[repo] = globs;
223
+ }
224
+ return { domain: record.domain, repos };
225
+ }
226
+ function globToRegExp(glob) {
227
+ let out = "^";
228
+ for (let i = 0; i < glob.length; i += 1) {
229
+ const char = glob[i];
230
+ if (char === "*") {
231
+ if (glob[i + 1] === "*") {
232
+ const slashAfter = glob[i + 2] === "/";
233
+ out += slashAfter ? "(?:.*/)?" : ".*";
234
+ i += slashAfter ? 2 : 1;
235
+ } else {
236
+ out += "[^/]*";
237
+ }
238
+ } else if (char === "?") {
239
+ out += "[^/]";
240
+ } else if (char === "[") {
241
+ const negated = glob[i + 1] === "!";
242
+ const bodyStart = i + 1 + (negated ? 1 : 0);
243
+ const close = glob.indexOf("]", glob[bodyStart] === "]" ? bodyStart + 1 : bodyStart);
244
+ if (close === -1) {
245
+ out += "\\[";
246
+ } else {
247
+ out += `[${negated ? "^" : ""}${glob.slice(bodyStart, close).replace(/[\\\]]/g, "\\$&")}]`;
248
+ i = close;
249
+ }
250
+ } else {
251
+ out += char.replace(/[.+^${}()|\\]/g, "\\$&");
252
+ }
253
+ }
254
+ return new RegExp(`${out}$`);
255
+ }
256
+ function matchOwnedFiles(files, globs) {
257
+ const patterns = globs.map(globToRegExp);
258
+ return files.filter((file) => patterns.some((pattern) => pattern.test(file)));
259
+ }
260
+ function classifyDomain(files, adapter, libraries) {
261
+ const matches = [];
262
+ for (const library of libraries) {
263
+ const owned = loadOwnedPaths(library.root);
264
+ if (!owned) continue;
265
+ const matched = matchOwnedFiles(files, owned.repos[adapter] ?? []);
266
+ if (matched.length > 0) matches.push({ domain: owned.domain, library, matched });
267
+ }
268
+ return matches.sort((a, b) => b.matched.length - a.matched.length);
269
+ }
270
+ function git(target, args) {
271
+ return execFileSync("git", ["-C", target, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
272
+ }
273
+ function defaultBaseRef(target) {
274
+ for (const candidate of ["origin/main", "main", "origin/develop", "develop"]) {
275
+ try {
276
+ git(target, ["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`]);
277
+ return candidate;
278
+ } catch {
279
+ }
280
+ }
281
+ return "HEAD";
282
+ }
283
+ function changedFiles(target, base) {
284
+ const ref = base ?? defaultBaseRef(target);
285
+ const lists = [
286
+ ref === "HEAD" ? "" : git(target, ["diff", "--name-only", `${ref}...HEAD`]),
287
+ git(target, ["diff", "--name-only", "HEAD"]),
288
+ git(target, ["ls-files", "--others", "--exclude-standard"])
289
+ ];
290
+ const files = /* @__PURE__ */ new Set();
291
+ for (const list of lists) for (const line of list.split("\n")) if (line.trim()) files.add(line.trim());
292
+ return { base: ref, files: [...files].sort() };
293
+ }
294
+ function parseSections(markdown) {
295
+ const lines = markdown.split("\n");
296
+ const introLines = [];
297
+ const sections = [];
298
+ let current;
299
+ let inFence = false;
300
+ for (const line of lines) {
301
+ if (/^```/.test(line)) {
302
+ inFence = !inFence;
303
+ continue;
304
+ }
305
+ if (inFence) continue;
306
+ const heading = /^## +(.+?)\s*$/.exec(line);
307
+ if (heading) {
308
+ if (current) sections.push(finishSection(current));
309
+ current = { title: heading[1].trim(), body: [] };
310
+ continue;
311
+ }
312
+ if (current) current.body.push(line);
313
+ else if (!/^# /.test(line)) introLines.push(line);
314
+ }
315
+ if (current) sections.push(finishSection(current));
316
+ return { intro: introLines.join("\n").trim(), sections };
317
+ }
318
+ function finishSection(section) {
319
+ const lines = section.body.map((line) => line.trim());
320
+ const firstBulletStart = lines.findIndex((line) => line.startsWith("- "));
321
+ const proseLines = firstBulletStart === -1 ? lines : lines.slice(0, firstBulletStart);
322
+ const proseStart = proseLines.findIndex((line) => line && !line.startsWith("#"));
323
+ let firstProse;
324
+ if (proseStart !== -1) {
325
+ const paragraph = [];
326
+ for (const line of proseLines.slice(proseStart)) {
327
+ if (!line || line.startsWith("#")) break;
328
+ paragraph.push(line);
329
+ }
330
+ firstProse = paragraph.join(" ");
331
+ }
332
+ let firstBullet;
333
+ if (firstBulletStart !== -1) {
334
+ const bullet = [lines[firstBulletStart].replace(/^- /, "")];
335
+ for (const line of lines.slice(firstBulletStart + 1)) {
336
+ if (!line || line.startsWith("- ") || line.startsWith("#")) break;
337
+ bullet.push(line);
338
+ }
339
+ firstBullet = bullet.join(" ");
340
+ }
341
+ const raw = firstProse ?? firstBullet ?? "";
342
+ return { title: section.title, summary: firstSentences(plainText(raw), SUMMARY_LIMIT) };
343
+ }
344
+ const SUMMARY_LIMIT = 220;
345
+ function firstSentences(text, limit) {
346
+ if (text.length <= limit) return text;
347
+ const sentences = text.split(/(?<=[.!?])\s+/);
348
+ let out = "";
349
+ for (const sentence of sentences) {
350
+ if (out && `${out} ${sentence}`.length > limit) break;
351
+ out = out ? `${out} ${sentence}` : sentence;
352
+ }
353
+ if (out.length > limit) out = `${out.slice(0, limit).replace(/\s+\S*$/, "")}\u2026`;
354
+ return out || text.slice(0, limit);
355
+ }
356
+ function plainText(markdown) {
357
+ return markdown.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\s+/g, " ").trim();
358
+ }
359
+ function loadDomainKnowledge(library, adapter) {
360
+ const knowledge = { library };
361
+ const antipatternsFile = path.join(library.root, "review", "antipatterns.md");
362
+ if (fs.existsSync(antipatternsFile)) {
363
+ const parsed = parseSections(fs.readFileSync(antipatternsFile, "utf8"));
364
+ knowledge.antipatterns = { ...parsed, file: antipatternsFile };
365
+ }
366
+ const platformFile = adapter ? path.join(library.root, "review", `antipatterns.${adapter}.md`) : void 0;
367
+ if (platformFile && fs.existsSync(platformFile)) {
368
+ const parsed = parseSections(fs.readFileSync(platformFile, "utf8"));
369
+ knowledge.antipatterns = knowledge.antipatterns ? { ...knowledge.antipatterns, sections: [...knowledge.antipatterns.sections, ...parsed.sections], platformFile } : { ...parsed, file: platformFile, platformFile };
370
+ }
371
+ const parityFile = path.join(library.root, "review", "parity.md");
372
+ if (fs.existsSync(parityFile)) {
373
+ knowledge.parity = { intro: parseSections(fs.readFileSync(parityFile, "utf8")).intro, file: parityFile };
374
+ }
375
+ const sharedFile = path.join(library.root, "review", "shared-packages.md");
376
+ if (fs.existsSync(sharedFile)) {
377
+ knowledge.sharedPackages = { intro: parseSections(fs.readFileSync(sharedFile, "utf8")).intro, file: sharedFile };
378
+ }
379
+ const owned = loadOwnedPaths(library.root);
380
+ if (owned) knowledge.ownedPaths = owned;
381
+ return knowledge;
382
+ }
383
+ const BASE_REVIEW_STEPS = [
384
+ "Correctness: trace every changed code path end to end, including error and empty states; confirm the change does what the task or PR says and nothing else.",
385
+ "Tests: each behavior change has a test at its best-fit layer (view, integration, unit); no mocked-away logic that hides the change under test.",
386
+ "Safety: no secrets, no widened permissions or network surface, no logging of user data; new dependencies justified.",
387
+ "Product wiring: i18n keys, telemetry events, feature flags, and error messages follow the repo conventions for the touched area.",
388
+ "Scope and hygiene: diff is minimal for the ask, no unrelated refactors or reformatting, commit and PR title follow conventional commits."
389
+ ];
390
+ const VERDICT_STEP = "Post the verdict: APPROVE or REQUEST CHANGES, findings ordered by severity with file:line, nits listed separately; state what was not checked and why.";
391
+ function parityAdapter(adapter) {
392
+ if (adapter === "mobile") return "extension";
393
+ if (adapter === "extension") return "mobile";
394
+ return void 0;
395
+ }
396
+ function renderReviewChecklist(input) {
397
+ const diffRange = input.since ? `${input.since}..HEAD` : `${input.base}...HEAD`;
398
+ const lines = [];
399
+ const scope = input.domain ? `${input.domain} (${input.adapter})` : input.adapter;
400
+ lines.push(`# Review checklist \u2014 ${scope}`, "");
401
+ lines.push(`Materialized by mm-harness ${input.harnessVersion} for ${input.target}.`);
402
+ if (input.knowledge) {
403
+ const { library } = input.knowledge;
404
+ lines.push(`Domain knowledge: ${library.name}${library.revision ? ` @ ${library.revision}` : ""} (${library.root}, via ${library.source}).`);
405
+ }
406
+ if (input.reference) {
407
+ lines.push(`Parity reference: ${input.reference.adapter} checkout at ${input.reference.root} (via ${input.reference.source}).`);
408
+ } else if (input.referenceMissing) {
409
+ lines.push(`Parity reference: not checked \u2014 ${input.referenceMissing}.`);
410
+ }
411
+ lines.push(`Scope: \`git diff ${diffRange}\`${input.since ? " (incremental re-review since the last verdict)" : ""}.`, "");
412
+ let step = 0;
413
+ const item = (text) => {
414
+ step += 1;
415
+ lines.push(`- [ ] ${step}. ${text}`);
416
+ };
417
+ lines.push("## Setup");
418
+ if (input.since) {
419
+ item(`Re-read the previous findings, then list only the files changed in \`git diff --name-only ${diffRange}\`; confirm each earlier finding is addressed or explicitly declined.`);
420
+ } else {
421
+ item(`Read the task or PR description and the linked ticket, then list the changed files with \`git diff --name-only ${diffRange}\`.`);
422
+ }
423
+ item(
424
+ input.domain ? `Confirm the domain is ${input.domain} (\`mm-harness domain\`) and open ${input.knowledge?.antipatterns ? "review/antipatterns.md" : "the domain library"} once before reading the diff.` : "Run `mm-harness domain` to check whether a team library owns the changed files; if one does, re-run with --domain."
425
+ );
426
+ lines.push("", "## Base review");
427
+ for (const text of BASE_REVIEW_STEPS) item(text);
428
+ if (input.knowledge?.antipatterns) {
429
+ lines.push("", `## Domain patterns (${input.domain})`);
430
+ for (const section of input.knowledge.antipatterns.sections) {
431
+ item(`${section.title}: ${section.summary}`);
432
+ }
433
+ }
434
+ lines.push("", "## Parity");
435
+ const parity = parityAdapter(input.adapter);
436
+ if (input.reference && parity) {
437
+ item(
438
+ `For each touched screen, hook, or formatter, compare it with its ${parity} counterpart at ${input.reference.root}` + (input.knowledge?.parity ? " using review/parity.md" : "") + `; record every divergence, and apply the library's parity rule to decide which platform must change (the reference platform is never changed to match the other).`
439
+ );
440
+ } else if (parity) {
441
+ item(`Parity with ${parity}: not checked \u2014 ${input.referenceMissing ?? "no reference checkout"}. Record this in the verdict.`);
442
+ } else {
443
+ item("Parity: core changes are consumed by both mobile and extension; confirm both consumers still compile against the public surface.");
444
+ }
445
+ lines.push("", "## Verdict");
446
+ item(VERDICT_STEP);
447
+ return `${lines.join("\n")}
448
+ `;
449
+ }
450
+ function renderReviewHelp(input) {
451
+ const lines = [];
452
+ lines.push(`mm-harness ${input.harnessVersion} review guide${input.domain ? ` \u2014 ${input.domain}` : ""}`, "");
453
+ lines.push("Base review (every PR, any domain):");
454
+ for (const [index, text] of BASE_REVIEW_STEPS.entries()) lines.push(` ${index + 1}. ${text}`);
455
+ lines.push(` ${BASE_REVIEW_STEPS.length + 1}. ${VERDICT_STEP}`, "");
456
+ if (input.knowledge) {
457
+ const { library, antipatterns, parity, sharedPackages } = input.knowledge;
458
+ lines.push(`Domain knowledge: ${library.name}${library.revision ? ` @ ${library.revision}` : ""} at ${library.root} (via ${library.source})`);
459
+ if (antipatterns) {
460
+ lines.push("", antipatterns.intro, "", "Anti-pattern families (one checklist line each):");
461
+ for (const section of antipatterns.sections) lines.push(` - ${section.title}`);
462
+ lines.push(` full text: ${antipatterns.file}${antipatterns.platformFile ? ` + ${antipatterns.platformFile}` : ""}`);
463
+ }
464
+ if (parity) lines.push("", "Parity:", ` ${parity.intro.split("\n").join("\n ")}`, ` map: ${parity.file}`);
465
+ if (sharedPackages) lines.push("", "Shared packages:", ` ${sharedPackages.intro.split("\n").join("\n ")}`, ` detail: ${sharedPackages.file}`);
466
+ lines.push("");
467
+ } else if (input.domain) {
468
+ lines.push(
469
+ `Domain knowledge: no library resolved for ${input.domain} on this machine (help never fetches).`,
470
+ ` Set RECIPE_LIBRARY_PATH="${input.domain}=<path>", run mm-harness config set libraries.${input.domain} <path>, or run review checklist --domain ${input.domain} to fetch a known library.`,
471
+ ""
472
+ );
473
+ } else {
474
+ lines.push("Add --domain <name> to compose a team library on top of the base review; `mm-harness domain` infers it from the changed files.", "");
475
+ }
476
+ if (input.reference) lines.push(`Parity reference: ${input.reference.adapter} at ${input.reference.root} (via ${input.reference.source})`);
477
+ else if (input.referenceMissing) lines.push(`Parity reference: not checked \u2014 ${input.referenceMissing}`);
478
+ lines.push("", `Materialize: mm-harness review checklist${input.domain ? ` --domain ${input.domain}` : ""} [--since <sha>]`);
479
+ return `${lines.join("\n")}
480
+ `;
481
+ }
482
+ export {
483
+ BASE_REVIEW_STEPS,
484
+ KNOWN_LIBRARIES,
485
+ REFERENCE_ENV_PREFIX,
486
+ REVIEW_ADAPTERS,
487
+ SKILLS_CACHE_DIR,
488
+ VERDICT_STEP,
489
+ changedFiles,
490
+ classifyDomain,
491
+ configPath,
492
+ defaultBaseRef,
493
+ discoverLibraries,
494
+ excludeCacheDir,
495
+ fetchLibrary,
496
+ gitExcludeFile,
497
+ globToRegExp,
498
+ libraryCachePath,
499
+ loadDomainKnowledge,
500
+ loadOwnedPaths,
501
+ matchOwnedFiles,
502
+ parityAdapter,
503
+ parseLibraryPathEnv,
504
+ parseSections,
505
+ readConfig,
506
+ referenceEnvName,
507
+ referenceHint,
508
+ renderReviewChecklist,
509
+ renderReviewHelp,
510
+ resolveLibrary,
511
+ resolveReference,
512
+ writeConfig
513
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.51.5",
3
+ "version": "0.51.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"