@deeeed/metamask-harness 0.51.5 → 0.51.6
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/CHANGELOG.md +24 -0
- package/README.md +25 -0
- package/adapters/extension/ensure-browser.sh +31 -7
- package/adapters/extension/launch-browser.cjs +154 -59
- package/adapters/extension/lib/macos-focus.cjs +207 -12
- package/adapters/extension/lib/validation-process-ownership.cjs +57 -2
- package/adapters/extension/live.sh +0 -2
- package/adapters/extension/stop-viewers.sh +1 -2
- package/dist/adapters/extension/validation-process-ownership.js +3 -2
- package/dist/adapters/slot-ports.js +65 -13
- package/dist/cli-commands.js +4 -1
- package/dist/cli.js +6 -0
- package/dist/command-contract.js +26 -1
- package/dist/commands/config.js +100 -0
- package/dist/commands/domain.js +56 -0
- package/dist/commands/help.js +2 -0
- package/dist/commands/review.js +135 -0
- package/dist/mm-harness-cli.js +71 -3
- package/dist/review/knowledge.js +513 -0
- package/package.json +1 -1
package/dist/mm-harness-cli.js
CHANGED
|
@@ -35,9 +35,77 @@ const REAL = [
|
|
|
35
35
|
--target <path> Checkout path (default: cwd)
|
|
36
36
|
--json Load the same guide as structured agent context
|
|
37
37
|
|
|
38
|
+
help review [--domain <name>] prints the review guide: the base review every PR
|
|
39
|
+
gets, plus the team library's anti-pattern families, parity rule, and shared
|
|
40
|
+
package notes when --domain names a library (see mm-harness review --help).
|
|
41
|
+
|
|
38
42
|
Example:
|
|
39
43
|
mm-harness help
|
|
40
|
-
mm-harness help --json
|
|
44
|
+
mm-harness help --json
|
|
45
|
+
mm-harness help review --domain perps`
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "review",
|
|
49
|
+
summary: "Materialize a review checklist: base review + team library patterns + parity + verdict.",
|
|
50
|
+
example: "mm-harness review checklist --domain perps",
|
|
51
|
+
helpText: `mm-harness review checklist [flags]
|
|
52
|
+
|
|
53
|
+
Writes a phase-structured checklist (Setup, Base review, Domain patterns,
|
|
54
|
+
Parity, Verdict) that mm-harness checklist mark can track. Domain patterns are
|
|
55
|
+
one line per "##" section of the library's review/antipatterns.md; Parity names
|
|
56
|
+
the other platform's checkout when one resolves and says "not checked" otherwise.
|
|
57
|
+
|
|
58
|
+
Library resolution for --domain <name>: RECIPE_LIBRARY_PATH entry \u2192 engineer
|
|
59
|
+
config (mm-harness config) \u2192 sibling directory beside the checkout \u2192 shallow
|
|
60
|
+
fetch into <checkout>/.skills-cache/<name> (known libraries only). Reference
|
|
61
|
+
checkout resolution: MM_HARNESS_REF_<ADAPTER> \u2192 engineer config \u2192 sibling
|
|
62
|
+
checkout of that adapter.
|
|
63
|
+
|
|
64
|
+
--domain <name> Team library to compose (default: MM_HARNESS_DOMAIN; none = base review only)
|
|
65
|
+
--since <sha> Incremental re-review: scope the checklist to <sha>..HEAD
|
|
66
|
+
--base <ref> Base ref for the diff range (default: origin/main when present)
|
|
67
|
+
--out <file> Also write the checklist to <file>
|
|
68
|
+
--adapter, --target, --json
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
mm-harness review checklist --domain perps
|
|
72
|
+
mm-harness review checklist --domain perps --since 4dd9be9 --out temp/tasks/review/CHECKLIST.md
|
|
73
|
+
mm-harness review checklist --json`
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "domain",
|
|
77
|
+
summary: "Which team library owns the change: declared value first, else owned-paths.json against the changed files.",
|
|
78
|
+
example: "mm-harness domain",
|
|
79
|
+
helpText: `mm-harness domain [flags]
|
|
80
|
+
|
|
81
|
+
Prints the domain name (or "none"). A declared --domain / MM_HARNESS_DOMAIN wins;
|
|
82
|
+
otherwise the changed files (base...HEAD, working tree, untracked) are matched
|
|
83
|
+
against owned-paths.json in every reachable library (RECIPE_LIBRARY_PATH,
|
|
84
|
+
mm-harness config, sibling directories, .skills-cache).
|
|
85
|
+
|
|
86
|
+
--domain <name> Declared domain (skips classification)
|
|
87
|
+
--base <ref> Base ref for the changed-file range (default: origin/main when present)
|
|
88
|
+
--adapter, --target, --json
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
mm-harness domain
|
|
92
|
+
mm-harness domain --json`
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "config",
|
|
96
|
+
summary: "Per-engineer machine locations for team libraries and reference checkouts.",
|
|
97
|
+
example: "mm-harness config set references.extension ~/dev/metamask-extension",
|
|
98
|
+
helpText: `mm-harness config <list|path|get <key>|set <key> <path>|unset <key>> [--json]
|
|
99
|
+
|
|
100
|
+
Stored outside every repo in ~/.mm-harness/config.json (override: MM_HARNESS_CONFIG).
|
|
101
|
+
Keys: libraries.<name> (team library checkout), references.<mobile|extension|core>
|
|
102
|
+
(the checkout used as the parity reference). Env always wins over config:
|
|
103
|
+
RECIPE_LIBRARY_PATH for libraries, MM_HARNESS_REF_<ADAPTER> for references.
|
|
104
|
+
|
|
105
|
+
Example:
|
|
106
|
+
mm-harness config set libraries.perps ~/dev/experimental-metamask-recipe-perps
|
|
107
|
+
mm-harness config set references.extension ~/dev/metamask-extension
|
|
108
|
+
mm-harness config list`
|
|
41
109
|
},
|
|
42
110
|
{
|
|
43
111
|
name: "tutorial",
|
|
@@ -783,12 +851,12 @@ const HELP_GROUPS = [
|
|
|
783
851
|
{
|
|
784
852
|
title: "DISCOVER",
|
|
785
853
|
blurb: "discover atomic actions and reusable recipes (--json is the agent-primary form)",
|
|
786
|
-
commands: ["help", "tutorial", "actions", "call", "execution-template"]
|
|
854
|
+
commands: ["help", "tutorial", "actions", "call", "execution-template", "domain", "config"]
|
|
787
855
|
},
|
|
788
856
|
{
|
|
789
857
|
title: "PROVE",
|
|
790
858
|
blurb: "run recipes and inspect readiness",
|
|
791
|
-
commands: ["run", "last", "doctor", "check", "checklist", "recipe-quality"]
|
|
859
|
+
commands: ["run", "last", "doctor", "check", "checklist", "review", "recipe-quality"]
|
|
792
860
|
},
|
|
793
861
|
{
|
|
794
862
|
title: "RUNTIME OVERLAY",
|
|
@@ -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
|
+
};
|